From d22c7826fe63ab58e44988597f3df260192507c8 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 16:14:29 -0500 Subject: [PATCH 01/61] Add config files --- install/captcha.html | 338 ++++++++++++++++ install/crowdsec/install.go | 376 ++++++++++++++++++ install/fs/crowdsec/acquis.yaml | 18 + install/fs/crowdsec/config.yaml | 12 + .../fs/crowdsec/local_api_credentials.yaml | 2 + install/fs/crowdsec/profiles.yaml | 25 ++ 6 files changed, 771 insertions(+) create mode 100644 install/captcha.html create mode 100644 install/crowdsec/install.go create mode 100644 install/fs/crowdsec/acquis.yaml create mode 100644 install/fs/crowdsec/config.yaml create mode 100644 install/fs/crowdsec/local_api_credentials.yaml create mode 100644 install/fs/crowdsec/profiles.yaml diff --git a/install/captcha.html b/install/captcha.html new file mode 100644 index 00000000..a40d37a3 --- /dev/null +++ b/install/captcha.html @@ -0,0 +1,338 @@ + + + + + CrowdSec Captcha + + + + + + + +
+
+
+ +

CrowdSec Captcha

+
+
+
+
+
+
+

This security check has been powered by

+ + + + + + + + + + + + + + + + + + + + + CrowdSec + +
+
+
+ + + \ No newline at end of file diff --git a/install/crowdsec/install.go b/install/crowdsec/install.go new file mode 100644 index 00000000..84f3089b --- /dev/null +++ b/install/crowdsec/install.go @@ -0,0 +1,376 @@ +package crowdsec + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" +) + +//go:embed fs/* +var configFiles embed.FS + +// Config holds all configuration values +type Config struct { + DomainName string + EnrollmentKey string + TurnstileSiteKey string + TurnstileSecretKey string + GID string + CrowdsecIP string + TraefikBouncerKey string + PangolinIP string +} + +// DockerContainer represents a Docker container +type DockerContainer struct { + NetworkSettings struct { + Networks map[string]struct { + IPAddress string `json:"IPAddress"` + } `json:"Networks"` + } `json:"NetworkSettings"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + // Create configuration + config := &Config{} + + // Run installation steps + if err := backupConfig(); err != nil { + return fmt.Errorf("backup failed: %v", err) + } + + if err := createPangolinNetwork(); err != nil { + return fmt.Errorf("network creation failed: %v", err) + } + + if err := modifyDockerCompose(); err != nil { + return fmt.Errorf("docker-compose modification failed: %v", err) + } + + if err := createConfigFiles(*config); err != nil { + return fmt.Errorf("config file creation failed: %v", err) + } + + if err := retrieveIPs(config); err != nil { + return fmt.Errorf("IP retrieval failed: %v", err) + } + + if err := retrieveBouncerKey(config); err != nil { + return fmt.Errorf("bouncer key retrieval failed: %v", err) + } + + if err := replacePlaceholders(config); err != nil { + return fmt.Errorf("placeholder replacement failed: %v", err) + } + + if err := deployStack(); err != nil { + return fmt.Errorf("deployment failed: %v", err) + } + + if err := verifyDeployment(); err != nil { + return fmt.Errorf("verification failed: %v", err) + } + + printInstructions() + return nil +} + +func backupConfig() error { + // Backup docker-compose.yml + if _, err := os.Stat("docker-compose.yml"); err == nil { + if err := copyFile("docker-compose.yml", "docker-compose.yml.backup"); err != nil { + return fmt.Errorf("failed to backup docker-compose.yml: %v", err) + } + } + + // Backup config directory + if _, err := os.Stat("config"); err == nil { + cmd := exec.Command("tar", "-czvf", "config.tar.gz", "config") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to backup config directory: %v", err) + } + } + + return nil +} + +func createPangolinNetwork() error { + // Check if network exists + cmd := exec.Command("docker", "network", "inspect", "pangolin") + if err := cmd.Run(); err == nil { + fmt.Println("pangolin network already exists") + return nil + } + + // Create network + cmd = exec.Command("docker", "network", "create", "pangolin") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create pangolin network: %v", err) + } + + return nil +} + +func modifyDockerCompose() error { + // Read existing docker-compose.yml + content, err := os.ReadFile("docker-compose.yml") + if err != nil { + return fmt.Errorf("failed to read docker-compose.yml: %v", err) + } + + // Verify required services exist + requiredServices := []string{"services:", "pangolin:", "gerbil:", "traefik:"} + for _, service := range requiredServices { + if !bytes.Contains(content, []byte(service)) { + return fmt.Errorf("required service %s not found in docker-compose.yml", service) + } + } + + // Add crowdsec service + modified := addCrowdsecService(string(content)) + + // Write modified content + if err := os.WriteFile("docker-compose.yml", []byte(modified), 0644); err != nil { + return fmt.Errorf("failed to write modified docker-compose.yml: %v", err) + } + + return nil +} + +func retrieveIPs(config *Config) error { + // Start required containers + cmd := exec.Command("docker", "compose", "up", "-d", "pangolin", "crowdsec") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to start containers: %v", err) + } + defer exec.Command("docker", "compose", "down").Run() + + // Wait for containers to start + time.Sleep(10 * time.Second) + + // Get Pangolin IP + pangolinIP, err := getContainerIP("pangolin") + if err != nil { + return fmt.Errorf("failed to get pangolin IP: %v", err) + } + config.PangolinIP = pangolinIP + + // Get CrowdSec IP + crowdsecIP, err := getContainerIP("crowdsec") + if err != nil { + return fmt.Errorf("failed to get crowdsec IP: %v", err) + } + config.CrowdsecIP = crowdsecIP + + return nil +} + +func retrieveBouncerKey(config *Config) error { + // Start crowdsec container + cmd := exec.Command("docker", "compose", "up", "-d", "crowdsec") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to start crowdsec: %v", err) + } + defer exec.Command("docker", "compose", "down").Run() + + // Wait for container to start + time.Sleep(10 * time.Second) + + // Get bouncer key + output, err := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer").Output() + if err != nil { + return fmt.Errorf("failed to get bouncer key: %v", err) + } + + // Parse key from output + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "key:") { + config.TraefikBouncerKey = strings.TrimSpace(strings.Split(line, ":")[1]) + break + } + } + + return nil +} + +func replacePlaceholders(config *Config) error { + // Get user input + fmt.Print("Enter your Domain Name (e.g., pangolin.example.com): ") + fmt.Scanln(&config.DomainName) + + fmt.Print("Enter your CrowdSec Enrollment Key: ") + fmt.Scanln(&config.EnrollmentKey) + + fmt.Print("Enter your Cloudflare Turnstile Site Key: ") + fmt.Scanln(&config.TurnstileSiteKey) + + fmt.Print("Enter your Cloudflare Turnstile Secret Key: ") + fmt.Scanln(&config.TurnstileSecretKey) + + fmt.Print("Enter your GID (or leave empty for default 1000): ") + gid := "" + fmt.Scanln(&gid) + if gid == "" { + config.GID = "1000" + } else { + config.GID = gid + } + + return nil +} + +func deployStack() error { + cmd := exec.Command("docker", "compose", "up", "-d") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to deploy stack: %v", err) + } + + fmt.Println("Stack deployed. Waiting 2 minutes for services to initialize...") + time.Sleep(2 * time.Minute) + return nil +} + +func verifyDeployment() error { + resp, err := exec.Command("curl", "-s", "http://localhost:6060/metrics").Output() + if err != nil { + return fmt.Errorf("failed to get metrics: %v", err) + } + + if !bytes.Contains(resp, []byte("appsec")) { + return fmt.Errorf("appsec metrics not found in response") + } + + return nil +} + +func printInstructions() { + fmt.Println(` +--- Testing Instructions --- +1. Test Captcha Implementation: + docker exec crowdsec cscli decisions add --ip YOUR_IP --type captcha -d 1h + (Replace YOUR_IP with your actual IP address) + +2. Verify decisions: + docker exec -it crowdsec cscli decisions list + +3. Test security by accessing DOMAIN_NAME/.env (should return 403) + (Replace DOMAIN_NAME with the domain you entered) + +--- Troubleshooting --- +1. If encountering 403 errors: + - Check Traefik logs: docker compose logs traefik -f + - Verify CrowdSec logs: docker compose logs crowdsec + +2. For plugin errors: + - Verify http notifications are commented out in profiles.yaml + - Restart services: docker compose restart traefik crowdsec + +3. For Captcha issues: + - Ensure Turnstile is configured in non-interactive mode + - Verify captcha.html configuration + - Check container network connectivity + +Useful Commands: +- View Traefik logs: docker compose logs traefik -f +- View CrowdSec logs: docker compose logs crowdsec +- List decisions: docker exec -it crowdsec cscli decisions list +- Check metrics: curl http://localhost:6060/metrics | grep appsec +`) +} + +// Helper functions + +func copyFile(src, dst string) error { + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return err + } + defer destination.Close() + + _, err = io.Copy(destination, source) + return err +} + +func getContainerIP(containerName string) (string, error) { + output, err := exec.Command("docker", "inspect", containerName).Output() + if err != nil { + return "", err + } + + var containers []DockerContainer + if err := json.Unmarshal(output, &containers); err != nil { + return "", err + } + + if len(containers) == 0 { + return "", fmt.Errorf("no container found") + } + + for _, network := range containers[0].NetworkSettings.Networks { + return network.IPAddress, nil + } + + return "", fmt.Errorf("no IP address found") +} + +func addCrowdsecService(content string) string { + // Implementation of adding crowdsec service to docker-compose.yml + // This would involve string manipulation or template rendering + // The actual implementation would depend on how you want to structure the docker-compose modifications + return content + ` + crowdsec: + image: crowdsecurity/crowdsec:latest + container_name: crowdsec + environment: + GID: "${GID-1000}" + COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules + ENROLL_INSTANCE_NAME: "pangolin-crowdsec" + PARSERS: crowdsecurity/whitelists + ENROLL_KEY: ${ENROLLMENT_KEY} + ACQUIRE_FILES: "/var/log/traefik/*.log" + ENROLL_TAGS: docker + networks: + - pangolin + healthcheck: + test: ["CMD", "cscli", "capi", "status"] + depends_on: + - gerbil + labels: + - "traefik.enable=false" + volumes: + - ./config/crowdsec:/etc/crowdsec + - ./config/crowdsec/db:/var/lib/crowdsec/data + - ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro + - ./config/crowdsec_logs/syslog:/var/log/syslog:ro + - ./config/crowdsec_logs:/var/log + - ./config/traefik/logs:/var/log/traefik + ports: + - 9090:9090 + - 6060:6060 + expose: + - 9090 + - 6060 + - 7422 + restart: unless-stopped + command: -t` +} diff --git a/install/fs/crowdsec/acquis.yaml b/install/fs/crowdsec/acquis.yaml new file mode 100644 index 00000000..74d8fd1c --- /dev/null +++ b/install/fs/crowdsec/acquis.yaml @@ -0,0 +1,18 @@ +filenames: + - /var/log/auth.log + - /var/log/syslog +labels: + type: syslog +--- +poll_without_inotify: false +filenames: + - /var/log/traefik/*.log +labels: + type: traefik +--- +listen_addr: 0.0.0.0:7422 +appsec_config: crowdsecurity/appsec-default +name: myAppSecComponent +source: appsec +labels: + type: appsec \ No newline at end of file diff --git a/install/fs/crowdsec/config.yaml b/install/fs/crowdsec/config.yaml new file mode 100644 index 00000000..0acf4635 --- /dev/null +++ b/install/fs/crowdsec/config.yaml @@ -0,0 +1,12 @@ +api: + client: + insecure_skip_verify: false + credentials_path: /etc/crowdsec/local_api_credentials.yaml + server: + log_level: info + listen_uri: 0.0.0.0:9090 + profiles_path: /etc/crowdsec/profiles.yaml + trusted_ips: + - 0.0.0.0/0 + - 127.0.0.1 + - ::1 \ No newline at end of file diff --git a/install/fs/crowdsec/local_api_credentials.yaml b/install/fs/crowdsec/local_api_credentials.yaml new file mode 100644 index 00000000..8776e4fd --- /dev/null +++ b/install/fs/crowdsec/local_api_credentials.yaml @@ -0,0 +1,2 @@ +url: http://0.0.0.0:9090 +login: localhost \ No newline at end of file diff --git a/install/fs/crowdsec/profiles.yaml b/install/fs/crowdsec/profiles.yaml new file mode 100644 index 00000000..3796b47f --- /dev/null +++ b/install/fs/crowdsec/profiles.yaml @@ -0,0 +1,25 @@ +name: captcha_remediation +filters: + - Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http" +decisions: + - type: captcha + duration: 4h +on_success: break + +--- +name: default_ip_remediation +filters: + - Alert.Remediation == true && Alert.GetScope() == "Ip" +decisions: + - type: ban + duration: 4h +on_success: break + +--- +name: default_range_remediation +filters: + - Alert.Remediation == true && Alert.GetScope() == "Range" +decisions: + - type: ban + duration: 4h +on_success: break \ No newline at end of file From 81c4199e87fdd066495101f85e47bcb6f49a1058 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 12 Feb 2025 21:56:13 -0500 Subject: [PATCH 02/61] Complete bash migration --- install/captcha.html | 338 ------------------ install/{crowdsec/install.go => crowdsec.go} | 271 ++++++-------- install/{fs => }/crowdsec/acquis.yaml | 0 install/{fs => }/crowdsec/config.yaml | 0 install/crowdsec/dynamic_config.yml | 108 ++++++ .../crowdsec/local_api_credentials.yaml | 0 install/{fs => }/crowdsec/profiles.yaml | 0 install/crowdsec/traefik_config.yml | 87 +++++ install/main.go | 1 + 9 files changed, 301 insertions(+), 504 deletions(-) delete mode 100644 install/captcha.html rename install/{crowdsec/install.go => crowdsec.go} (55%) rename install/{fs => }/crowdsec/acquis.yaml (100%) rename install/{fs => }/crowdsec/config.yaml (100%) create mode 100644 install/crowdsec/dynamic_config.yml rename install/{fs => }/crowdsec/local_api_credentials.yaml (100%) rename install/{fs => }/crowdsec/profiles.yaml (100%) create mode 100644 install/crowdsec/traefik_config.yml diff --git a/install/captcha.html b/install/captcha.html deleted file mode 100644 index a40d37a3..00000000 --- a/install/captcha.html +++ /dev/null @@ -1,338 +0,0 @@ - - - - - CrowdSec Captcha - - - - - - - -
-
-
- -

CrowdSec Captcha

-
-
-
-
-
-
-

This security check has been powered by

- - - - - - - - - - - - - - - - - - - - - CrowdSec - -
-
-
- - - \ No newline at end of file diff --git a/install/crowdsec/install.go b/install/crowdsec.go similarity index 55% rename from install/crowdsec/install.go rename to install/crowdsec.go index 84f3089b..187fea88 100644 --- a/install/crowdsec/install.go +++ b/install/crowdsec.go @@ -1,31 +1,21 @@ -package crowdsec +package main import ( "bytes" "embed" - "encoding/json" "fmt" + "html/template" "io" + "io/fs" "os" "os/exec" + "path/filepath" "strings" "time" ) -//go:embed fs/* -var configFiles embed.FS - -// Config holds all configuration values -type Config struct { - DomainName string - EnrollmentKey string - TurnstileSiteKey string - TurnstileSecretKey string - GID string - CrowdsecIP string - TraefikBouncerKey string - PangolinIP string -} +//go:embed crowdsec/* +var configCrowdsecFiles embed.FS // DockerContainer represents a Docker container type DockerContainer struct { @@ -36,14 +26,7 @@ type DockerContainer struct { } `json:"NetworkSettings"` } -func main() { - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func run() error { +func installCrowdsec() error { // Create configuration config := &Config{} @@ -52,30 +35,21 @@ func run() error { return fmt.Errorf("backup failed: %v", err) } - if err := createPangolinNetwork(); err != nil { - return fmt.Errorf("network creation failed: %v", err) - } - if err := modifyDockerCompose(); err != nil { return fmt.Errorf("docker-compose modification failed: %v", err) } - if err := createConfigFiles(*config); err != nil { + if err := createCrowdsecFiles(*config); err != nil { return fmt.Errorf("config file creation failed: %v", err) } - if err := retrieveIPs(config); err != nil { - return fmt.Errorf("IP retrieval failed: %v", err) - } + moveFile("config/crowdsec/traefik_config.yaml", "config/traefik/traefik_config.yaml") + moveFile("config/crowdsec/dynamic.yaml", "config/traefik/dynamic.yaml") if err := retrieveBouncerKey(config); err != nil { return fmt.Errorf("bouncer key retrieval failed: %v", err) } - if err := replacePlaceholders(config); err != nil { - return fmt.Errorf("placeholder replacement failed: %v", err) - } - if err := deployStack(); err != nil { return fmt.Errorf("deployment failed: %v", err) } @@ -84,7 +58,6 @@ func run() error { return fmt.Errorf("verification failed: %v", err) } - printInstructions() return nil } @@ -107,23 +80,6 @@ func backupConfig() error { return nil } -func createPangolinNetwork() error { - // Check if network exists - cmd := exec.Command("docker", "network", "inspect", "pangolin") - if err := cmd.Run(); err == nil { - fmt.Println("pangolin network already exists") - return nil - } - - // Create network - cmd = exec.Command("docker", "network", "create", "pangolin") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to create pangolin network: %v", err) - } - - return nil -} - func modifyDockerCompose() error { // Read existing docker-compose.yml content, err := os.ReadFile("docker-compose.yml") @@ -150,34 +106,6 @@ func modifyDockerCompose() error { return nil } -func retrieveIPs(config *Config) error { - // Start required containers - cmd := exec.Command("docker", "compose", "up", "-d", "pangolin", "crowdsec") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to start containers: %v", err) - } - defer exec.Command("docker", "compose", "down").Run() - - // Wait for containers to start - time.Sleep(10 * time.Second) - - // Get Pangolin IP - pangolinIP, err := getContainerIP("pangolin") - if err != nil { - return fmt.Errorf("failed to get pangolin IP: %v", err) - } - config.PangolinIP = pangolinIP - - // Get CrowdSec IP - crowdsecIP, err := getContainerIP("crowdsec") - if err != nil { - return fmt.Errorf("failed to get crowdsec IP: %v", err) - } - config.CrowdsecIP = crowdsecIP - - return nil -} - func retrieveBouncerKey(config *Config) error { // Start crowdsec container cmd := exec.Command("docker", "compose", "up", "-d", "crowdsec") @@ -207,32 +135,6 @@ func retrieveBouncerKey(config *Config) error { return nil } -func replacePlaceholders(config *Config) error { - // Get user input - fmt.Print("Enter your Domain Name (e.g., pangolin.example.com): ") - fmt.Scanln(&config.DomainName) - - fmt.Print("Enter your CrowdSec Enrollment Key: ") - fmt.Scanln(&config.EnrollmentKey) - - fmt.Print("Enter your Cloudflare Turnstile Site Key: ") - fmt.Scanln(&config.TurnstileSiteKey) - - fmt.Print("Enter your Cloudflare Turnstile Secret Key: ") - fmt.Scanln(&config.TurnstileSecretKey) - - fmt.Print("Enter your GID (or leave empty for default 1000): ") - gid := "" - fmt.Scanln(&gid) - if gid == "" { - config.GID = "1000" - } else { - config.GID = gid - } - - return nil -} - func deployStack() error { cmd := exec.Command("docker", "compose", "up", "-d") if err := cmd.Run(); err != nil { @@ -257,43 +159,6 @@ func verifyDeployment() error { return nil } -func printInstructions() { - fmt.Println(` ---- Testing Instructions --- -1. Test Captcha Implementation: - docker exec crowdsec cscli decisions add --ip YOUR_IP --type captcha -d 1h - (Replace YOUR_IP with your actual IP address) - -2. Verify decisions: - docker exec -it crowdsec cscli decisions list - -3. Test security by accessing DOMAIN_NAME/.env (should return 403) - (Replace DOMAIN_NAME with the domain you entered) - ---- Troubleshooting --- -1. If encountering 403 errors: - - Check Traefik logs: docker compose logs traefik -f - - Verify CrowdSec logs: docker compose logs crowdsec - -2. For plugin errors: - - Verify http notifications are commented out in profiles.yaml - - Restart services: docker compose restart traefik crowdsec - -3. For Captcha issues: - - Ensure Turnstile is configured in non-interactive mode - - Verify captcha.html configuration - - Check container network connectivity - -Useful Commands: -- View Traefik logs: docker compose logs traefik -f -- View CrowdSec logs: docker compose logs crowdsec -- List decisions: docker exec -it crowdsec cscli decisions list -- Check metrics: curl http://localhost:6060/metrics | grep appsec -`) -} - -// Helper functions - func copyFile(src, dst string) error { source, err := os.Open(src) if err != nil { @@ -311,26 +176,12 @@ func copyFile(src, dst string) error { return err } -func getContainerIP(containerName string) (string, error) { - output, err := exec.Command("docker", "inspect", containerName).Output() - if err != nil { - return "", err +func moveFile(src, dst string) error { + if err := copyFile(src, dst); err != nil { + return err } - var containers []DockerContainer - if err := json.Unmarshal(output, &containers); err != nil { - return "", err - } - - if len(containers) == 0 { - return "", fmt.Errorf("no container found") - } - - for _, network := range containers[0].NetworkSettings.Networks { - return network.IPAddress, nil - } - - return "", fmt.Errorf("no IP address found") + return os.Remove(src) } func addCrowdsecService(content string) string { @@ -346,11 +197,8 @@ func addCrowdsecService(content string) string { COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules ENROLL_INSTANCE_NAME: "pangolin-crowdsec" PARSERS: crowdsecurity/whitelists - ENROLL_KEY: ${ENROLLMENT_KEY} ACQUIRE_FILES: "/var/log/traefik/*.log" ENROLL_TAGS: docker - networks: - - pangolin healthcheck: test: ["CMD", "cscli", "capi", "status"] depends_on: @@ -374,3 +222,94 @@ func addCrowdsecService(content string) string { restart: unless-stopped command: -t` } + +func createCrowdsecFiles(config Config) error { + // Walk through all embedded files + err := fs.WalkDir(configCrowdsecFiles, "crowdsec", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the root fs directory itself + if path == "fs" { + return nil + } + + // Get the relative path by removing the "fs/" prefix + relPath := strings.TrimPrefix(path, "fs/") + + // skip .DS_Store + if strings.Contains(relPath, ".DS_Store") { + return nil + } + + // Create the full output path under "config/" + outPath := filepath.Join("config", relPath) + + if d.IsDir() { + // Create directory + if err := os.MkdirAll(outPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %v", outPath, err) + } + return nil + } + + // Read the template file + content, err := configFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %v", path, err) + } + + // Parse template + tmpl, err := template.New(d.Name()).Parse(string(content)) + if err != nil { + return fmt.Errorf("failed to parse template %s: %v", path, err) + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err) + } + + // Create output file + outFile, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("failed to create %s: %v", outPath, err) + } + defer outFile.Close() + + // Execute template + if err := tmpl.Execute(outFile, config); err != nil { + return fmt.Errorf("failed to execute template %s: %v", path, err) + } + + return nil + }) + + if err != nil { + return fmt.Errorf("error walking config files: %v", err) + } + + // get the current directory + dir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %v", err) + } + + sourcePath := filepath.Join(dir, "config/docker-compose.yml") + destPath := filepath.Join(dir, "docker-compose.yml") + + // Check if source file exists + if _, err := os.Stat(sourcePath); err != nil { + return fmt.Errorf("source docker-compose.yml not found: %v", err) + } + + // Try to move the file + err = os.Rename(sourcePath, destPath) + if err != nil { + return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v", + sourcePath, destPath, err) + } + + return nil +} diff --git a/install/fs/crowdsec/acquis.yaml b/install/crowdsec/acquis.yaml similarity index 100% rename from install/fs/crowdsec/acquis.yaml rename to install/crowdsec/acquis.yaml diff --git a/install/fs/crowdsec/config.yaml b/install/crowdsec/config.yaml similarity index 100% rename from install/fs/crowdsec/config.yaml rename to install/crowdsec/config.yaml diff --git a/install/crowdsec/dynamic_config.yml b/install/crowdsec/dynamic_config.yml new file mode 100644 index 00000000..9175b143 --- /dev/null +++ b/install/crowdsec/dynamic_config.yml @@ -0,0 +1,108 @@ +http: + middlewares: + redirect-to-https: + redirectScheme: + scheme: https + default-whitelist: # Whitelist middleware for internal IPs + ipWhiteList: # Internal IP addresses + sourceRange: # Internal IP addresses + - "10.0.0.0/8" # Internal IP addresses + - "192.168.0.0/16" # Internal IP addresses + - "172.16.0.0/12" # Internal IP addresses + # Basic security headers + security-headers: + headers: + customResponseHeaders: # Custom response headers + Server: "" # Remove server header + X-Powered-By: "" # Remove powered by header + X-Forwarded-Proto: "https" # Set forwarded proto to https + sslProxyHeaders: # SSL proxy headers + X-Forwarded-Proto: "https" # Set forwarded proto to https + hostsProxyHeaders: # Hosts proxy headers + - "X-Forwarded-Host" # Set forwarded host + contentTypeNosniff: true # Prevent MIME sniffing + customFrameOptionsValue: "SAMEORIGIN" # Set frame options + referrerPolicy: "strict-origin-when-cross-origin" # Set referrer policy + forceSTSHeader: true # Force STS header + stsIncludeSubdomains: true # Include subdomains + stsSeconds: 63072000 # STS seconds + stsPreload: true # Preload STS + # CrowdSec configuration with proper IP forwarding + crowdsec: + plugin: + crowdsec: + enabled: true # Enable CrowdSec plugin + logLevel: INFO # Log level + updateIntervalSeconds: 15 # Update interval + updateMaxFailure: 0 # Update max failure + defaultDecisionSeconds: 15 # Default decision seconds + httpTimeoutSeconds: 10 # HTTP timeout + crowdsecMode: live # CrowdSec mode + crowdsecAppsecEnabled: true # Enable AppSec + crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later + crowdsecAppsecFailureBlock: true # Block on failure + crowdsecAppsecUnreachableBlock: true # Block on unreachable + crowdsecLapiKey: "{{.TraefikBouncerKey}}" # CrowdSec API key which you noted down later + crowdsecLapiHost: crowdsec:9090 # CrowdSec + crowdsecLapiScheme: http # CrowdSec API scheme + forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs + - "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE) + clientTrustedIPs: # Client trusted IPs (CHANGE MADE HERE) + - "10.0.0.0/8" # Internal LAN IP addresses + - "172.16.0.0/12" # Internal LAN IP addresses + - "192.168.0.0/16" # Internal LAN IP addresses + - "100.89.137.0/20" # Internal LAN IP addresses + + routers: + # HTTP to HTTPS redirect router + main-app-router-redirect: + rule: "Host(`{{.DomainName}}`)" # Dynamic Domain Name + service: next-service + entryPoints: + - web + middlewares: + - redirect-to-https + + # Next.js router (handles everything except API and WebSocket paths) + next-router: + rule: "Host(`{{.DomainName}}`) && !PathPrefix(`/api/v1`)" # Dynamic Domain Name + service: next-service + entryPoints: + - websecure + middlewares: + - security-headers # Add security headers middleware + tls: + certResolver: letsencrypt + + # API router (handles /api/v1 paths) + api-router: + rule: "Host(`{{.DomainName}}`) && PathPrefix(`/api/v1`)" # Dynamic Domain Name + service: api-service + entryPoints: + - websecure + middlewares: + - security-headers # Add security headers middleware + tls: + certResolver: letsencrypt + + # WebSocket router + ws-router: + rule: "Host(`{{.DomainName}}`)" # Dynamic Domain Name + service: api-service + entryPoints: + - websecure + middlewares: + - security-headers # Add security headers middleware + tls: + certResolver: letsencrypt + + services: + next-service: + loadBalancer: + servers: + - url: "http://pangolin:3002" # Next.js server + + api-service: + loadBalancer: + servers: + - url: "http://pangolin:3000" # API/WebSocket server \ No newline at end of file diff --git a/install/fs/crowdsec/local_api_credentials.yaml b/install/crowdsec/local_api_credentials.yaml similarity index 100% rename from install/fs/crowdsec/local_api_credentials.yaml rename to install/crowdsec/local_api_credentials.yaml diff --git a/install/fs/crowdsec/profiles.yaml b/install/crowdsec/profiles.yaml similarity index 100% rename from install/fs/crowdsec/profiles.yaml rename to install/crowdsec/profiles.yaml diff --git a/install/crowdsec/traefik_config.yml b/install/crowdsec/traefik_config.yml new file mode 100644 index 00000000..2ac9125c --- /dev/null +++ b/install/crowdsec/traefik_config.yml @@ -0,0 +1,87 @@ +api: + insecure: true + dashboard: true + +providers: + http: + endpoint: "http://pangolin:3001/api/v1/traefik-config" + pollInterval: "5s" + file: + filename: "/etc/traefik/dynamic_config.yml" + +experimental: + plugins: + badger: + moduleName: "github.com/fosrl/badger" + version: "{{.BadgerVersion}}" + crowdsec: # CrowdSec plugin configuration added + moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + version: "v1.3.5" + +log: + level: "INFO" + format: "json" # Log format changed to json for better parsing + +accessLog: # We enable access logs as json + filePath: "/var/log/traefik/access.log" + format: json + filters: + statusCodes: + - "200-299" # Success codes + - "400-499" # Client errors + - "500-599" # Server errors + retryAttempts: true + minDuration: "100ms" # Increased to focus on slower requests + bufferingSize: 100 # Add buffering for better performance + fields: + defaultMode: drop # Start with dropping all fields + names: + ClientAddr: keep # Keep client address for IP tracking + ClientHost: keep # Keep client host for IP tracking + RequestMethod: keep # Keep request method for tracking + RequestPath: keep # Keep request path for tracking + RequestProtocol: keep # Keep request protocol for tracking + DownstreamStatus: keep # Keep downstream status for tracking + DownstreamContentSize: keep # Keep downstream content size for tracking + Duration: keep # Keep request duration for tracking + ServiceName: keep # Keep service name for tracking + StartUTC: keep # Keep start time for tracking + TLSVersion: keep # Keep TLS version for tracking + TLSCipher: keep # Keep TLS cipher for tracking + RetryAttempts: keep # Keep retry attempts for tracking + headers: + defaultMode: drop # Start with dropping all headers + names: + User-Agent: keep # Keep user agent for tracking + X-Real-Ip: keep # Keep real IP for tracking + X-Forwarded-For: keep # Keep forwarded IP for tracking + X-Forwarded-Proto: keep # Keep forwarded protocol for tracking + Content-Type: keep # Keep content type for tracking + Authorization: redact # Redact sensitive information + Cookie: redact # Redact sensitive information + +certificatesResolvers: + letsencrypt: + acme: + httpChallenge: + entryPoint: web + email: "{{.LetsEncryptEmail}}" + storage: "/letsencrypt/acme.json" + caServer: "https://acme-v02.api.letsencrypt.org/directory" + +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + transport: + respondingTimeouts: + readTimeout: "30m" + http: + tls: + certResolver: "letsencrypt" + middlewares: # CHANGE MADE HERE (BOUNCER ENABLED) !!! + - crowdsec@file + +serversTransport: + insecureSkipVerify: true \ No newline at end of file diff --git a/install/main.go b/install/main.go index 4f2deb3a..d5bf5e1e 100644 --- a/install/main.go +++ b/install/main.go @@ -45,6 +45,7 @@ type Config struct { EmailSMTPPass string EmailNoReply string InstallGerbil bool + TraefikBouncerKey string } func main() { From 60449afca5f3186e31c5d8210e116c9a49de884b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 13 Feb 2025 22:16:52 -0500 Subject: [PATCH 03/61] Reorg; create crowdsec folder properly now --- install/config.go | 216 ++++++++++++++ install/{fs => config}/config.yml | 0 install/{ => config}/crowdsec/acquis.yaml | 0 install/{ => config}/crowdsec/config.yaml | 0 .../{ => config}/crowdsec/dynamic_config.yml | 8 +- .../crowdsec/local_api_credentials.yaml | 0 install/{ => config}/crowdsec/profiles.yaml | 0 .../{ => config}/crowdsec/traefik_config.yml | 0 install/{fs => config}/docker-compose.yml | 5 +- .../{fs => config}/traefik/dynamic_config.yml | 0 .../{fs => config}/traefik/traefik_config.yml | 0 install/crowdsec.go | 267 ++---------------- install/go.mod | 1 + install/go.sum | 3 + install/main.go | 134 ++++++--- 15 files changed, 349 insertions(+), 285 deletions(-) create mode 100644 install/config.go rename install/{fs => config}/config.yml (100%) rename install/{ => config}/crowdsec/acquis.yaml (100%) rename install/{ => config}/crowdsec/config.yaml (100%) rename install/{ => config}/crowdsec/dynamic_config.yml (92%) rename install/{ => config}/crowdsec/local_api_credentials.yaml (100%) rename install/{ => config}/crowdsec/profiles.yaml (100%) rename install/{ => config}/crowdsec/traefik_config.yml (100%) rename install/{fs => config}/docker-compose.yml (97%) rename install/{fs => config}/traefik/dynamic_config.yml (100%) rename install/{fs => config}/traefik/traefik_config.yml (100%) diff --git a/install/config.go b/install/config.go new file mode 100644 index 00000000..fa10fa53 --- /dev/null +++ b/install/config.go @@ -0,0 +1,216 @@ +package main + +import ( + "bytes" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// TraefikConfig represents the structure of the main Traefik configuration +type TraefikConfig struct { + Experimental struct { + Plugins struct { + Badger struct { + Version string `yaml:"version"` + } `yaml:"badger"` + } `yaml:"plugins"` + } `yaml:"experimental"` + CertificatesResolvers struct { + LetsEncrypt struct { + Acme struct { + Email string `yaml:"email"` + } `yaml:"acme"` + } `yaml:"letsencrypt"` + } `yaml:"certificatesResolvers"` +} + +// DynamicConfig represents the structure of the dynamic configuration +type DynamicConfig struct { + HTTP struct { + Routers map[string]struct { + Rule string `yaml:"rule"` + } `yaml:"routers"` + } `yaml:"http"` +} + +// ConfigValues holds the extracted configuration values +type ConfigValues struct { + DashboardDomain string + LetsEncryptEmail string + BadgerVersion string +} + +// ReadTraefikConfig reads and extracts values from Traefik configuration files +func ReadTraefikConfig(mainConfigPath, dynamicConfigPath string) (*ConfigValues, error) { + // Read main config file + mainConfigData, err := os.ReadFile(mainConfigPath) + if err != nil { + return nil, fmt.Errorf("error reading main config file: %w", err) + } + + var mainConfig TraefikConfig + if err := yaml.Unmarshal(mainConfigData, &mainConfig); err != nil { + return nil, fmt.Errorf("error parsing main config file: %w", err) + } + + // Read dynamic config file + dynamicConfigData, err := os.ReadFile(dynamicConfigPath) + if err != nil { + return nil, fmt.Errorf("error reading dynamic config file: %w", err) + } + + var dynamicConfig DynamicConfig + if err := yaml.Unmarshal(dynamicConfigData, &dynamicConfig); err != nil { + return nil, fmt.Errorf("error parsing dynamic config file: %w", err) + } + + // Extract values + values := &ConfigValues{ + BadgerVersion: mainConfig.Experimental.Plugins.Badger.Version, + LetsEncryptEmail: mainConfig.CertificatesResolvers.LetsEncrypt.Acme.Email, + } + + // Extract DashboardDomain from router rules + // Look for it in the main router rules + for _, router := range dynamicConfig.HTTP.Routers { + if router.Rule != "" { + // Extract domain from Host(`mydomain.com`) + if domain := extractDomainFromRule(router.Rule); domain != "" { + values.DashboardDomain = domain + break + } + } + } + + return values, nil +} + +// extractDomainFromRule extracts the domain from a router rule +func extractDomainFromRule(rule string) string { + // Look for the Host(`mydomain.com`) pattern + if start := findPattern(rule, "Host(`"); start != -1 { + end := findPattern(rule[start:], "`)") + if end != -1 { + return rule[start+6 : start+end] + } + } + return "" +} + +// findPattern finds the start of a pattern in a string +func findPattern(s, pattern string) int { + return bytes.Index([]byte(s), []byte(pattern)) +} + +type Volume string +type Port string +type Expose string + +type HealthCheck struct { + Test []string `yaml:"test,omitempty"` + Interval string `yaml:"interval,omitempty"` + Timeout string `yaml:"timeout,omitempty"` + Retries int `yaml:"retries,omitempty"` +} + +type DependsOnCondition struct { + Condition string `yaml:"condition,omitempty"` +} + +type Service struct { + Image string `yaml:"image,omitempty"` + ContainerName string `yaml:"container_name,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` + HealthCheck *HealthCheck `yaml:"healthcheck,omitempty"` + DependsOn map[string]DependsOnCondition `yaml:"depends_on,omitempty"` + Labels []string `yaml:"labels,omitempty"` + Volumes []Volume `yaml:"volumes,omitempty"` + Ports []Port `yaml:"ports,omitempty"` + Expose []Expose `yaml:"expose,omitempty"` + Restart string `yaml:"restart,omitempty"` + Command interface{} `yaml:"command,omitempty"` + NetworkMode string `yaml:"network_mode,omitempty"` + CapAdd []string `yaml:"cap_add,omitempty"` +} + +type Network struct { + Driver string `yaml:"driver,omitempty"` + Name string `yaml:"name,omitempty"` +} + +type DockerConfig struct { + Version string `yaml:"version,omitempty"` + Services map[string]Service `yaml:"services"` + Networks map[string]Network `yaml:"networks,omitempty"` +} + +func AddCrowdSecService(configPath string) error { + // Read existing config + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + + // Parse existing config + var config DockerConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return err + } + + // Create CrowdSec service + crowdsecService := Service{ + Image: "crowdsecurity/crowdsec:latest", + ContainerName: "crowdsec", + Environment: map[string]string{ + "GID": "${GID-1000}", + "COLLECTIONS": "crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules", + "ENROLL_INSTANCE_NAME": "pangolin-crowdsec", + "PARSERS": "crowdsecurity/whitelists", + "ACQUIRE_FILES": "/var/log/traefik/*.log", + "ENROLL_TAGS": "docker", + }, + HealthCheck: &HealthCheck{ + Test: []string{"CMD", "cscli", "capi", "status"}, + }, + DependsOn: map[string]DependsOnCondition{ + "gerbil": {}, + }, + Labels: []string{"traefik.enable=false"}, + Volumes: []Volume{ + "./config/crowdsec:/etc/crowdsec", + "./config/crowdsec/db:/var/lib/crowdsec/data", + "./config/crowdsec_logs/auth.log:/var/log/auth.log:ro", + "./config/crowdsec_logs/syslog:/var/log/syslog:ro", + "./config/crowdsec_logs:/var/log", + "./config/traefik/logs:/var/log/traefik", + }, + Ports: []Port{ + "9090:9090", + "6060:6060", + }, + Expose: []Expose{ + "9090", + "6060", + "7422", + }, + Restart: "unless-stopped", + Command: "-t", + } + + // Add CrowdSec service to config + if config.Services == nil { + config.Services = make(map[string]Service) + } + config.Services["crowdsec"] = crowdsecService + + // Marshal config with better formatting + yamlData, err := yaml.Marshal(&config) + if err != nil { + return err + } + + // Write config back to file + return os.WriteFile(configPath, yamlData, 0644) +} diff --git a/install/fs/config.yml b/install/config/config.yml similarity index 100% rename from install/fs/config.yml rename to install/config/config.yml diff --git a/install/crowdsec/acquis.yaml b/install/config/crowdsec/acquis.yaml similarity index 100% rename from install/crowdsec/acquis.yaml rename to install/config/crowdsec/acquis.yaml diff --git a/install/crowdsec/config.yaml b/install/config/crowdsec/config.yaml similarity index 100% rename from install/crowdsec/config.yaml rename to install/config/crowdsec/config.yaml diff --git a/install/crowdsec/dynamic_config.yml b/install/config/crowdsec/dynamic_config.yml similarity index 92% rename from install/crowdsec/dynamic_config.yml rename to install/config/crowdsec/dynamic_config.yml index 9175b143..d2556971 100644 --- a/install/crowdsec/dynamic_config.yml +++ b/install/config/crowdsec/dynamic_config.yml @@ -56,7 +56,7 @@ http: routers: # HTTP to HTTPS redirect router main-app-router-redirect: - rule: "Host(`{{.DomainName}}`)" # Dynamic Domain Name + rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name service: next-service entryPoints: - web @@ -65,7 +65,7 @@ http: # Next.js router (handles everything except API and WebSocket paths) next-router: - rule: "Host(`{{.DomainName}}`) && !PathPrefix(`/api/v1`)" # Dynamic Domain Name + rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" # Dynamic Domain Name service: next-service entryPoints: - websecure @@ -76,7 +76,7 @@ http: # API router (handles /api/v1 paths) api-router: - rule: "Host(`{{.DomainName}}`) && PathPrefix(`/api/v1`)" # Dynamic Domain Name + rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" # Dynamic Domain Name service: api-service entryPoints: - websecure @@ -87,7 +87,7 @@ http: # WebSocket router ws-router: - rule: "Host(`{{.DomainName}}`)" # Dynamic Domain Name + rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name service: api-service entryPoints: - websecure diff --git a/install/crowdsec/local_api_credentials.yaml b/install/config/crowdsec/local_api_credentials.yaml similarity index 100% rename from install/crowdsec/local_api_credentials.yaml rename to install/config/crowdsec/local_api_credentials.yaml diff --git a/install/crowdsec/profiles.yaml b/install/config/crowdsec/profiles.yaml similarity index 100% rename from install/crowdsec/profiles.yaml rename to install/config/crowdsec/profiles.yaml diff --git a/install/crowdsec/traefik_config.yml b/install/config/crowdsec/traefik_config.yml similarity index 100% rename from install/crowdsec/traefik_config.yml rename to install/config/crowdsec/traefik_config.yml diff --git a/install/fs/docker-compose.yml b/install/config/docker-compose.yml similarity index 97% rename from install/fs/docker-compose.yml rename to install/config/docker-compose.yml index ea673eb0..42604ab4 100644 --- a/install/fs/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -10,7 +10,6 @@ services: interval: "3s" timeout: "3s" retries: 5 - {{if .InstallGerbil}} gerbil: image: fosrl/gerbil:{{.GerbilVersion}} @@ -34,15 +33,13 @@ services: - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode {{end}} - traefik: image: traefik:v3.3.3 container_name: traefik restart: unless-stopped {{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service -{{end}} -{{if not .InstallGerbil}} +{{end}}{{if not .InstallGerbil}} ports: - 443:443 - 80:80 diff --git a/install/fs/traefik/dynamic_config.yml b/install/config/traefik/dynamic_config.yml similarity index 100% rename from install/fs/traefik/dynamic_config.yml rename to install/config/traefik/dynamic_config.yml diff --git a/install/fs/traefik/traefik_config.yml b/install/config/traefik/traefik_config.yml similarity index 100% rename from install/fs/traefik/traefik_config.yml rename to install/config/traefik/traefik_config.yml diff --git a/install/crowdsec.go b/install/crowdsec.go index 187fea88..02e9c71b 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -2,45 +2,26 @@ package main import ( "bytes" - "embed" "fmt" - "html/template" - "io" - "io/fs" "os" "os/exec" - "path/filepath" "strings" "time" ) -//go:embed crowdsec/* -var configCrowdsecFiles embed.FS - -// DockerContainer represents a Docker container -type DockerContainer struct { - NetworkSettings struct { - Networks map[string]struct { - IPAddress string `json:"IPAddress"` - } `json:"Networks"` - } `json:"NetworkSettings"` -} - -func installCrowdsec() error { - // Create configuration - config := &Config{} - +func installCrowdsec(config Config) error { // Run installation steps if err := backupConfig(); err != nil { return fmt.Errorf("backup failed: %v", err) } - if err := modifyDockerCompose(); err != nil { - return fmt.Errorf("docker-compose modification failed: %v", err) + if err := AddCrowdSecService("docker-compose.yml"); err != nil { + return fmt.Errorf("crowdsec service addition failed: %v", err) } - if err := createCrowdsecFiles(*config); err != nil { - return fmt.Errorf("config file creation failed: %v", err) + if err := createConfigFiles(config); err != nil { + fmt.Printf("Error creating config files: %v\n", err) + os.Exit(1) } moveFile("config/crowdsec/traefik_config.yaml", "config/traefik/traefik_config.yaml") @@ -50,14 +31,6 @@ func installCrowdsec() error { return fmt.Errorf("bouncer key retrieval failed: %v", err) } - if err := deployStack(); err != nil { - return fmt.Errorf("deployment failed: %v", err) - } - - if err := verifyDeployment(); err != nil { - return fmt.Errorf("verification failed: %v", err) - } - return nil } @@ -80,33 +53,7 @@ func backupConfig() error { return nil } -func modifyDockerCompose() error { - // Read existing docker-compose.yml - content, err := os.ReadFile("docker-compose.yml") - if err != nil { - return fmt.Errorf("failed to read docker-compose.yml: %v", err) - } - - // Verify required services exist - requiredServices := []string{"services:", "pangolin:", "gerbil:", "traefik:"} - for _, service := range requiredServices { - if !bytes.Contains(content, []byte(service)) { - return fmt.Errorf("required service %s not found in docker-compose.yml", service) - } - } - - // Add crowdsec service - modified := addCrowdsecService(string(content)) - - // Write modified content - if err := os.WriteFile("docker-compose.yml", []byte(modified), 0644); err != nil { - return fmt.Errorf("failed to write modified docker-compose.yml: %v", err) - } - - return nil -} - -func retrieveBouncerKey(config *Config) error { +func retrieveBouncerKey(config Config) error { // Start crowdsec container cmd := exec.Command("docker", "compose", "up", "-d", "crowdsec") if err := cmd.Run(); err != nil { @@ -114,8 +61,24 @@ func retrieveBouncerKey(config *Config) error { } defer exec.Command("docker", "compose", "down").Run() - // Wait for container to start - time.Sleep(10 * time.Second) + // verify that the container is running if not keep waiting for 10 more seconds then return an error + count := 0 + for { + cmd := exec.Command("docker", "inspect", "-f", "{{.State.Running}}", "crowdsec") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to inspect crowdsec container: %v", err) + } + if strings.TrimSpace(string(output)) == "true" { + break + } + time.Sleep(10 * time.Second) + count++ + + if count > 4 { + return fmt.Errorf("crowdsec container is not running") + } + } // Get bouncer key output, err := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer").Output() @@ -135,181 +98,13 @@ func retrieveBouncerKey(config *Config) error { return nil } -func deployStack() error { - cmd := exec.Command("docker", "compose", "up", "-d") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to deploy stack: %v", err) - } - - fmt.Println("Stack deployed. Waiting 2 minutes for services to initialize...") - time.Sleep(2 * time.Minute) - return nil -} - -func verifyDeployment() error { - resp, err := exec.Command("curl", "-s", "http://localhost:6060/metrics").Output() +func checkIsCrowdsecInstalledInCompose() bool { + // Read docker-compose.yml + content, err := os.ReadFile("docker-compose.yml") if err != nil { - return fmt.Errorf("failed to get metrics: %v", err) + return false } - if !bytes.Contains(resp, []byte("appsec")) { - return fmt.Errorf("appsec metrics not found in response") - } - - return nil -} - -func copyFile(src, dst string) error { - source, err := os.Open(src) - if err != nil { - return err - } - defer source.Close() - - destination, err := os.Create(dst) - if err != nil { - return err - } - defer destination.Close() - - _, err = io.Copy(destination, source) - return err -} - -func moveFile(src, dst string) error { - if err := copyFile(src, dst); err != nil { - return err - } - - return os.Remove(src) -} - -func addCrowdsecService(content string) string { - // Implementation of adding crowdsec service to docker-compose.yml - // This would involve string manipulation or template rendering - // The actual implementation would depend on how you want to structure the docker-compose modifications - return content + ` - crowdsec: - image: crowdsecurity/crowdsec:latest - container_name: crowdsec - environment: - GID: "${GID-1000}" - COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules - ENROLL_INSTANCE_NAME: "pangolin-crowdsec" - PARSERS: crowdsecurity/whitelists - ACQUIRE_FILES: "/var/log/traefik/*.log" - ENROLL_TAGS: docker - healthcheck: - test: ["CMD", "cscli", "capi", "status"] - depends_on: - - gerbil - labels: - - "traefik.enable=false" - volumes: - - ./config/crowdsec:/etc/crowdsec - - ./config/crowdsec/db:/var/lib/crowdsec/data - - ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro - - ./config/crowdsec_logs/syslog:/var/log/syslog:ro - - ./config/crowdsec_logs:/var/log - - ./config/traefik/logs:/var/log/traefik - ports: - - 9090:9090 - - 6060:6060 - expose: - - 9090 - - 6060 - - 7422 - restart: unless-stopped - command: -t` -} - -func createCrowdsecFiles(config Config) error { - // Walk through all embedded files - err := fs.WalkDir(configCrowdsecFiles, "crowdsec", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // Skip the root fs directory itself - if path == "fs" { - return nil - } - - // Get the relative path by removing the "fs/" prefix - relPath := strings.TrimPrefix(path, "fs/") - - // skip .DS_Store - if strings.Contains(relPath, ".DS_Store") { - return nil - } - - // Create the full output path under "config/" - outPath := filepath.Join("config", relPath) - - if d.IsDir() { - // Create directory - if err := os.MkdirAll(outPath, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %v", outPath, err) - } - return nil - } - - // Read the template file - content, err := configFiles.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read %s: %v", path, err) - } - - // Parse template - tmpl, err := template.New(d.Name()).Parse(string(content)) - if err != nil { - return fmt.Errorf("failed to parse template %s: %v", path, err) - } - - // Ensure parent directory exists - if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { - return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err) - } - - // Create output file - outFile, err := os.Create(outPath) - if err != nil { - return fmt.Errorf("failed to create %s: %v", outPath, err) - } - defer outFile.Close() - - // Execute template - if err := tmpl.Execute(outFile, config); err != nil { - return fmt.Errorf("failed to execute template %s: %v", path, err) - } - - return nil - }) - - if err != nil { - return fmt.Errorf("error walking config files: %v", err) - } - - // get the current directory - dir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %v", err) - } - - sourcePath := filepath.Join(dir, "config/docker-compose.yml") - destPath := filepath.Join(dir, "docker-compose.yml") - - // Check if source file exists - if _, err := os.Stat(sourcePath); err != nil { - return fmt.Errorf("source docker-compose.yml not found: %v", err) - } - - // Try to move the file - err = os.Rename(sourcePath, destPath) - if err != nil { - return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v", - sourcePath, destPath, err) - } - - return nil + // Check for crowdsec service + return bytes.Contains(content, []byte("crowdsec:")) } diff --git a/install/go.mod b/install/go.mod index 85cf49e4..536ac2dd 100644 --- a/install/go.mod +++ b/install/go.mod @@ -5,4 +5,5 @@ go 1.23.0 require ( golang.org/x/sys v0.29.0 // indirect golang.org/x/term v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/install/go.sum b/install/go.sum index f05f63b4..3316e039 100644 --- a/install/go.sum +++ b/install/go.sum @@ -2,3 +2,6 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install/main.go b/install/main.go index d5bf5e1e..4f7c7df6 100644 --- a/install/main.go +++ b/install/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "os" + "io" "os/exec" "path/filepath" "runtime" @@ -24,7 +25,7 @@ func loadVersions(config *Config) { config.BadgerVersion = "replaceme" } -//go:embed fs/* +//go:embed config/* var configFiles embed.FS type Config struct { @@ -46,6 +47,7 @@ type Config struct { EmailNoReply string InstallGerbil bool TraefikBouncerKey string + DoCrowdsecInstall bool } func main() { @@ -57,9 +59,12 @@ func main() { os.Exit(1) } + var config Config + config.DoCrowdsecInstall = false + // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { - config := collectUserInput(reader) + config = collectUserInput(reader) loadVersions(&config) @@ -68,18 +73,53 @@ func main() { os.Exit(1) } + moveFile("config/docker-compose.yml", "docker-compose.yml") + if !isDockerInstalled() && runtime.GOOS == "linux" { if readBool(reader, "Docker is not installed. Would you like to install it?", true) { installDocker() } } + + fmt.Println("\n=== Starting installation ===") + + if isDockerInstalled() { + if readBool(reader, "Would you like to install and start the containers?", true) { + pullAndStartContainers() + } + } } else { - fmt.Println("Config file already exists... skipping configuration") + fmt.Println("Looks like you already installed, so I am going to do the setup...") } - if isDockerInstalled() { - if readBool(reader, "Would you like to install and start the containers?", true) { - pullAndStartContainers() + if !checkIsCrowdsecInstalledInCompose() { + fmt.Println("\n=== Crowdsec Install ===") + // check if crowdsec is installed + if readBool(reader, "Would you like to install Crowdsec?", true) { + + if config.DashboardDomain == "" { + traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml") + if err != nil { + fmt.Printf("Error reading config: %v\n", err) + return + } + config.DashboardDomain = traefikConfig.DashboardDomain + config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail + config.BadgerVersion = traefikConfig.BadgerVersion + + // print the values and check if they are right + fmt.Println("Detected values:") + fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain) + 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) + } + } + + config.DoCrowdsecInstall = true + installCrowdsec(config) } } @@ -137,6 +177,11 @@ func readInt(reader *bufio.Reader, prompt string, defaultValue int) int { return value } +func isDockerFilePresent() bool { + _, err := os.Stat("docker-compose.yml") + return !os.IsNotExist(err) +} + func collectUserInput(reader *bufio.Reader) Config { config := Config{} @@ -262,31 +307,33 @@ func createConfigFiles(config Config) error { os.MkdirAll("config/logs", 0755) // Walk through all embedded files - err := fs.WalkDir(configFiles, "fs", func(path string, d fs.DirEntry, err error) error { + err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } // Skip the root fs directory itself - if path == "fs" { + if path == "config" { return nil } - // Get the relative path by removing the "fs/" prefix - relPath := strings.TrimPrefix(path, "fs/") + if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") { + return nil + } + + if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") { + return nil + } // skip .DS_Store - if strings.Contains(relPath, ".DS_Store") { + if strings.Contains(path, ".DS_Store") { return nil } - // Create the full output path under "config/" - outPath := filepath.Join("config", relPath) - if d.IsDir() { // Create directory - if err := os.MkdirAll(outPath, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %v", outPath, err) + if err := os.MkdirAll(path, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %v", path, err) } return nil } @@ -304,14 +351,14 @@ func createConfigFiles(config Config) error { } // Ensure parent directory exists - if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { - return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create parent directory for %s: %v", path, err) } // Create output file - outFile, err := os.Create(outPath) + outFile, err := os.Create(path) if err != nil { - return fmt.Errorf("failed to create %s: %v", outPath, err) + return fmt.Errorf("failed to create %s: %v", path, err) } defer outFile.Close() @@ -327,30 +374,10 @@ func createConfigFiles(config Config) error { return fmt.Errorf("error walking config files: %v", err) } - // get the current directory - dir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %v", err) - } - - sourcePath := filepath.Join(dir, "config/docker-compose.yml") - destPath := filepath.Join(dir, "docker-compose.yml") - - // Check if source file exists - if _, err := os.Stat(sourcePath); err != nil { - return fmt.Errorf("source docker-compose.yml not found: %v", err) - } - - // Try to move the file - err = os.Rename(sourcePath, destPath) - if err != nil { - return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v", - sourcePath, destPath, err) - } - return nil } + func installDocker() error { // Detect Linux distribution cmd := exec.Command("cat", "/etc/os-release") @@ -491,3 +518,28 @@ func pullAndStartContainers() error { return nil } + +func copyFile(src, dst string) error { + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return err + } + defer destination.Close() + + _, err = io.Copy(destination, source) + return err +} + +func moveFile(src, dst string) error { + if err := copyFile(src, dst); err != nil { + return err + } + + return os.Remove(src) +} \ No newline at end of file From 62238948e0002d7d5492caf251f2ba513c83e045 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 20:22:26 -0500 Subject: [PATCH 04/61] save --- server/db/schema.ts | 21 ++++++++++++++++++++- server/lib/config.ts | 26 +++++++++++++++++--------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/server/db/schema.ts b/server/db/schema.ts index 3380cdbf..a583e4ad 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -1,12 +1,26 @@ import { InferSelectModel } from "drizzle-orm"; import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +export const domains = sqliteTable("domains", { + domainId: integer("domainId").primaryKey({ autoIncrement: true }), + baseDomain: text("domain").notNull().unique() +}); + export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), domain: text("domain").notNull() }); +export const orgDomains = sqliteTable("orgDomains", { + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + domainId: integer("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }) +}); + export const sites = sqliteTable("sites", { siteId: integer("siteId").primaryKey({ autoIncrement: true }), orgId: text("orgId") @@ -43,6 +57,9 @@ export const resources = sqliteTable("resources", { name: text("name").notNull(), subdomain: text("subdomain"), fullDomain: text("fullDomain"), + domainId: integer("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), blockAccess: integer("blockAccess", { mode: "boolean" }) .notNull() @@ -55,7 +72,9 @@ export const resources = sqliteTable("resources", { .notNull() .default(false), isBaseDomain: integer("isBaseDomain", { mode: "boolean" }), - applyRules: integer("applyRules", { mode: "boolean" }).notNull().default(false) + applyRules: integer("applyRules", { mode: "boolean" }) + .notNull() + .default(false) }); export const targets = sqliteTable("targets", { diff --git a/server/lib/config.ts b/server/lib/config.ts index 7c5ad227..bf1f17dc 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -34,15 +34,27 @@ const configSchema = z.object({ .transform(getEnvOrYaml("APP_DASHBOARDURL")) .pipe(z.string().url()) .transform((url) => url.toLowerCase()), - base_domain: hostnameSchema - .optional() - .transform(getEnvOrYaml("APP_BASEDOMAIN")) - .pipe(hostnameSchema) - .transform((url) => url.toLowerCase()), log_level: z.enum(["debug", "info", "warn", "error"]), save_logs: z.boolean(), log_failed_attempts: z.boolean().optional() }), + domains: z + .array( + z.object({ + base_domain: hostnameSchema.transform((url) => + url.toLowerCase() + ) + }) + ) + .refine( + (data) => { + const baseDomains = data.map((d) => d.base_domain); + return new Set(baseDomains).size === baseDomains.length; + }, + { + message: "Base domains must be unique" + } + ), server: z.object({ external_port: portSchema .optional() @@ -283,10 +295,6 @@ export class Config { return this.rawConfig; } - public getBaseDomain(): string { - return this.rawConfig.app.base_domain; - } - public getNoReplyEmail(): string | undefined { return ( this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user From bdee036ab422683c9534d9a2d589d93702972bd2 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 15 Feb 2025 17:08:11 -0500 Subject: [PATCH 05/61] Add name; Resolves #190 --- docker-compose.example.yml | 8 +++----- install/fs/docker-compose.yml | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index bc5ad10c..ad755174 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,5 +1,4 @@ -version: "3.7" - +name: pangolin services: pangolin: image: fosrl/pangolin:latest @@ -32,7 +31,6 @@ services: - SYS_MODULE ports: - 51820:51820/udp - - 8080:8080 # Port for traefik because of the network_mode - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode @@ -47,8 +45,8 @@ services: command: - --configFile=/etc/traefik/traefik_config.yml volumes: - - ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration - - ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates + - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration + - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates networks: default: diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml index ea673eb0..b26e0257 100644 --- a/install/fs/docker-compose.yml +++ b/install/fs/docker-compose.yml @@ -1,3 +1,4 @@ +name: pangolin services: pangolin: image: fosrl/pangolin:{{.PangolinVersion}} From b862e1aeef667ccd7e0157639d95dcea6e14d48f Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 15 Feb 2025 17:43:44 -0500 Subject: [PATCH 06/61] Add h2c as target method; Resolves #115 --- .../settings/resources/[resourceId]/connectivity/page.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index c565b525..67434404 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -421,6 +421,7 @@ export default function ReverseProxyTargets(props: { http https + h2c ) @@ -517,6 +518,9 @@ export default function ReverseProxyTargets(props: { https + + h2c + From 7bf820a4bfd19c5278e2d39463151fe1aa5fd7f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 15 Feb 2025 17:48:27 -0500 Subject: [PATCH 07/61] Clean off ports for 80 and 443 hosts --- server/routers/badger/verifySession.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index fc1c85f5..69314cbc 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -90,7 +90,15 @@ export async function verifyResourceSession( const clientIp = requestIp?.split(":")[0]; - const resourceCacheKey = `resource:${host}`; + let cleanHost = host; + // if the host ends with :443 or :80 remove it + if (cleanHost.endsWith(":443")) { + cleanHost = cleanHost.slice(0, -4); + } else if (cleanHost.endsWith(":80")) { + cleanHost = cleanHost.slice(0, -3); + } + + const resourceCacheKey = `resource:${cleanHost}`; let resourceData: | { resource: Resource | null; @@ -111,11 +119,11 @@ export async function verifyResourceSession( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) - .where(eq(resources.fullDomain, host)) + .where(eq(resources.fullDomain, cleanHost)) .limit(1); if (!result) { - logger.debug("Resource not found", host); + logger.debug("Resource not found", cleanHost); return notAllowed(res); } @@ -131,7 +139,7 @@ export async function verifyResourceSession( const { resource, pincode, password } = resourceData; if (!resource) { - logger.debug("Resource not found", host); + logger.debug("Resource not found", cleanHost); return notAllowed(res); } From dabd4a055c2c63996c585018ce3e7d696a3d7868 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 15 Feb 2025 22:00:31 -0500 Subject: [PATCH 08/61] Creating structure correctly --- install/config.go | 2 +- install/crowdsec.go | 4 ++-- install/input.txt | 12 ++++++++++++ install/main.go | 38 ++++++++++++++++++++------------------ 4 files changed, 35 insertions(+), 21 deletions(-) create mode 100644 install/input.txt diff --git a/install/config.go b/install/config.go index fa10fa53..49577048 100644 --- a/install/config.go +++ b/install/config.go @@ -164,7 +164,7 @@ func AddCrowdSecService(configPath string) error { Image: "crowdsecurity/crowdsec:latest", ContainerName: "crowdsec", Environment: map[string]string{ - "GID": "${GID-1000}", + "GID": "1000", "COLLECTIONS": "crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules", "ENROLL_INSTANCE_NAME": "pangolin-crowdsec", "PARSERS": "crowdsecurity/whitelists", diff --git a/install/crowdsec.go b/install/crowdsec.go index 02e9c71b..1d9bef65 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -24,8 +24,8 @@ func installCrowdsec(config Config) error { os.Exit(1) } - moveFile("config/crowdsec/traefik_config.yaml", "config/traefik/traefik_config.yaml") - moveFile("config/crowdsec/dynamic.yaml", "config/traefik/dynamic.yaml") + // moveFile("config/crowdsec/traefik_config.yml", "config/traefik/traefik_config.yml") + moveFile("config/crowdsec/dynamic.yml", "config/traefik/dynamic.yml") if err := retrieveBouncerKey(config); err != nil { return fmt.Errorf("bouncer key retrieval failed: %v", err) diff --git a/install/input.txt b/install/input.txt new file mode 100644 index 00000000..9bca8081 --- /dev/null +++ b/install/input.txt @@ -0,0 +1,12 @@ +example.com +pangolin.example.com +admin@example.com +yes +admin@example.com +Password123! +Password123! +yes +no +no +no +yes diff --git a/install/main.go b/install/main.go index 4f7c7df6..e5ad98b9 100644 --- a/install/main.go +++ b/install/main.go @@ -140,22 +140,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string return input } -func readPassword(prompt string) string { - fmt.Print(prompt + ": ") - - // Read password without echo - 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) - } - return input +func readPassword(prompt string, reader *bufio.Reader) string { + if term.IsTerminal(int(syscall.Stdin)) { + fmt.Print(prompt + ": ") + // Read password without echo if we're in a terminal + password, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() // Add a newline since ReadPassword doesn't add one + if err != nil { + return "" + } + input := strings.TrimSpace(string(password)) + if input == "" { + return readPassword(prompt, reader) + } + return input + } else { + // Fallback to reading from stdin if not in a terminal + return readString(reader, prompt, "") + } } func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool { @@ -196,8 +198,8 @@ func collectUserInput(reader *bufio.Reader) Config { fmt.Println("\n=== Admin User Configuration ===") config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain) for { - pass1 := readPassword("Create admin user password") - pass2 := readPassword("Confirm admin user password") + pass1 := readPassword("Create admin user password", reader) + pass2 := readPassword("Confirm admin user password", reader) if pass1 != pass2 { fmt.Println("Passwords do not match") From 532d3696c2300ef1bad88482309cd1d00be304d9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 15 Feb 2025 22:41:39 -0500 Subject: [PATCH 09/61] sync config managed domains to db --- server/db/schema.ts | 14 +++-- server/lib/config.ts | 23 ++------ server/setup/copyInConfig.ts | 108 +++++++++++++++++++++++++++++------ 3 files changed, 106 insertions(+), 39 deletions(-) diff --git a/server/db/schema.ts b/server/db/schema.ts index a583e4ad..6cbe0fc5 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -2,21 +2,23 @@ import { InferSelectModel } from "drizzle-orm"; import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { - domainId: integer("domainId").primaryKey({ autoIncrement: true }), - baseDomain: text("domain").notNull().unique() + domainId: text("domainId").primaryKey(), + baseDomain: text("baseDomain").notNull().unique(), + configManaged: integer("configManaged", { mode: "boolean" }) + .notNull() + .default(false) }); export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), - name: text("name").notNull(), - domain: text("domain").notNull() + name: text("name").notNull() }); export const orgDomains = sqliteTable("orgDomains", { orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - domainId: integer("domainId") + domainId: text("domainId") .notNull() .references(() => domains.domainId, { onDelete: "cascade" }) }); @@ -57,7 +59,7 @@ export const resources = sqliteTable("resources", { name: text("name").notNull(), subdomain: text("subdomain"), fullDomain: text("fullDomain"), - domainId: integer("domainId").references(() => domains.domainId, { + domainId: text("domainId").references(() => domains.domainId, { onDelete: "set null" }), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), diff --git a/server/lib/config.ts b/server/lib/config.ts index bf1f17dc..04f00335 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -38,23 +38,12 @@ const configSchema = z.object({ save_logs: z.boolean(), log_failed_attempts: z.boolean().optional() }), - domains: z - .array( - z.object({ - base_domain: hostnameSchema.transform((url) => - url.toLowerCase() - ) - }) - ) - .refine( - (data) => { - const baseDomains = data.map((d) => d.base_domain); - return new Set(baseDomains).size === baseDomains.length; - }, - { - message: "Base domains must be unique" - } - ), + domains: z.record( + z.string(), + z.object({ + base_domain: hostnameSchema.transform((url) => url.toLowerCase()) + }) + ), server: z.object({ external_port: portSchema .optional() diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index 8f3af8d6..88d7bcdc 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -1,40 +1,116 @@ import { db } from "@server/db"; -import { exitNodes, orgs, resources } from "../db/schema"; +import { domains, exitNodes, orgDomains, orgs, resources } from "../db/schema"; import config from "@server/lib/config"; import { eq, ne } from "drizzle-orm"; import logger from "@server/logger"; export async function copyInConfig() { - const domain = config.getBaseDomain(); const endpoint = config.getRawConfig().gerbil.base_endpoint; const listenPort = config.getRawConfig().gerbil.start_port; - // update the domain on all of the orgs where the domain is not equal to the new domain - // TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary - await db.update(orgs).set({ domain }).where(ne(orgs.domain, domain)); - - // TODO: eventually each exit node could have a different endpoint - await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, endpoint)); - // TODO: eventually each exit node could have a different port - await db.update(exitNodes).set({ listenPort }).where(ne(exitNodes.listenPort, listenPort)); - - // update all resources fullDomain to use the new domain await db.transaction(async (trx) => { - const allResources = await trx.select().from(resources); + const rawDomains = config.getRawConfig().domains; + + const configDomains = Object.entries(rawDomains).map( + ([key, value]) => ({ + domainId: key, + baseDomain: value.base_domain.toLowerCase() + }) + ); + + const existingDomains = await trx + .select() + .from(domains) + .where(eq(domains.configManaged, true)); + const existingDomainKeys = new Set( + existingDomains.map((d) => d.domainId) + ); + + const configDomainKeys = new Set(configDomains.map((d) => d.domainId)); + for (const existingDomain of existingDomains) { + if (!configDomainKeys.has(existingDomain.domainId)) { + await trx + .delete(domains) + .where(eq(domains.domainId, existingDomain.domainId)) + .execute(); + } + } + + for (const { domainId, baseDomain } of configDomains) { + if (existingDomainKeys.has(domainId)) { + await trx + .update(domains) + .set({ baseDomain }) + .where(eq(domains.domainId, domainId)) + .execute(); + } else { + await trx + .insert(domains) + .values({ domainId, baseDomain, configManaged: true }) + .execute(); + } + } + + const allResources = await trx + .select() + .from(resources) + .leftJoin(domains, eq(domains.domainId, resources.domainId)); + + for (const { resources: resource, domains: domain } of allResources) { + if (!resource || !domain) { + continue; + } + + if (!domain.configManaged) { + continue; + } - for (const resource of allResources) { let fullDomain = ""; if (resource.isBaseDomain) { - fullDomain = domain; + fullDomain = domain.baseDomain; } else { fullDomain = `${resource.subdomain}.${domain}`; } + await trx .update(resources) .set({ fullDomain }) .where(eq(resources.resourceId, resource.resourceId)); } + + const allOrgs = await trx.select().from(orgs); + + const existingOrgDomains = await trx.select().from(orgDomains); + const existingOrgDomainSet = new Set( + existingOrgDomains.map((od) => `${od.orgId}-${od.domainId}`) + ); + + const newOrgDomains = []; + for (const org of allOrgs) { + for (const domain of configDomains) { + const key = `${org.orgId}-${domain.domainId}`; + if (!existingOrgDomainSet.has(key)) { + newOrgDomains.push({ + orgId: org.orgId, + domainId: domain.domainId + }); + } + } + } + + if (newOrgDomains.length > 0) { + await trx.insert(orgDomains).values(newOrgDomains).execute(); + } }); - logger.info(`Updated orgs with new domain (${domain})`); + // TODO: eventually each exit node could have a different endpoint + await db + .update(exitNodes) + .set({ endpoint }) + .where(ne(exitNodes.endpoint, endpoint)); + // TODO: eventually each exit node could have a different port + await db + .update(exitNodes) + .set({ listenPort }) + .where(ne(exitNodes.listenPort, listenPort)); } From d3d523b2b8f1de0d25d176be134515a0614b0721 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 16 Feb 2025 11:26:45 -0500 Subject: [PATCH 10/61] Refactor docker copy and keep entrypoints --- install/config.go | 268 +++++++++++++-------- install/config/crowdsec/docker-compose.yml | 35 +++ install/config/crowdsec/traefik_config.yml | 2 +- install/crowdsec.go | 35 ++- 4 files changed, 230 insertions(+), 110 deletions(-) create mode 100644 install/config/crowdsec/docker-compose.yml diff --git a/install/config.go b/install/config.go index 49577048..1ebfbecb 100644 --- a/install/config.go +++ b/install/config.go @@ -104,113 +104,175 @@ func findPattern(s, pattern string) int { return bytes.Index([]byte(s), []byte(pattern)) } -type Volume string -type Port string -type Expose string - -type HealthCheck struct { - Test []string `yaml:"test,omitempty"` - Interval string `yaml:"interval,omitempty"` - Timeout string `yaml:"timeout,omitempty"` - Retries int `yaml:"retries,omitempty"` -} - -type DependsOnCondition struct { - Condition string `yaml:"condition,omitempty"` -} - -type Service struct { - Image string `yaml:"image,omitempty"` - ContainerName string `yaml:"container_name,omitempty"` - Environment map[string]string `yaml:"environment,omitempty"` - HealthCheck *HealthCheck `yaml:"healthcheck,omitempty"` - DependsOn map[string]DependsOnCondition `yaml:"depends_on,omitempty"` - Labels []string `yaml:"labels,omitempty"` - Volumes []Volume `yaml:"volumes,omitempty"` - Ports []Port `yaml:"ports,omitempty"` - Expose []Expose `yaml:"expose,omitempty"` - Restart string `yaml:"restart,omitempty"` - Command interface{} `yaml:"command,omitempty"` - NetworkMode string `yaml:"network_mode,omitempty"` - CapAdd []string `yaml:"cap_add,omitempty"` -} - -type Network struct { - Driver string `yaml:"driver,omitempty"` - Name string `yaml:"name,omitempty"` -} - -type DockerConfig struct { - Version string `yaml:"version,omitempty"` - Services map[string]Service `yaml:"services"` - Networks map[string]Network `yaml:"networks,omitempty"` -} - -func AddCrowdSecService(configPath string) error { - // Read existing config - data, err := os.ReadFile(configPath) +func copyEntryPoints(sourceFile, destFile string) error { + // Read source file + sourceData, err := os.ReadFile(sourceFile) if err != nil { - return err + return fmt.Errorf("error reading source file: %w", err) } - // Parse existing config - var config DockerConfig - if err := yaml.Unmarshal(data, &config); err != nil { - return err - } - - // Create CrowdSec service - crowdsecService := Service{ - Image: "crowdsecurity/crowdsec:latest", - ContainerName: "crowdsec", - Environment: map[string]string{ - "GID": "1000", - "COLLECTIONS": "crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules", - "ENROLL_INSTANCE_NAME": "pangolin-crowdsec", - "PARSERS": "crowdsecurity/whitelists", - "ACQUIRE_FILES": "/var/log/traefik/*.log", - "ENROLL_TAGS": "docker", - }, - HealthCheck: &HealthCheck{ - Test: []string{"CMD", "cscli", "capi", "status"}, - }, - DependsOn: map[string]DependsOnCondition{ - "gerbil": {}, - }, - Labels: []string{"traefik.enable=false"}, - Volumes: []Volume{ - "./config/crowdsec:/etc/crowdsec", - "./config/crowdsec/db:/var/lib/crowdsec/data", - "./config/crowdsec_logs/auth.log:/var/log/auth.log:ro", - "./config/crowdsec_logs/syslog:/var/log/syslog:ro", - "./config/crowdsec_logs:/var/log", - "./config/traefik/logs:/var/log/traefik", - }, - Ports: []Port{ - "9090:9090", - "6060:6060", - }, - Expose: []Expose{ - "9090", - "6060", - "7422", - }, - Restart: "unless-stopped", - Command: "-t", - } - - // Add CrowdSec service to config - if config.Services == nil { - config.Services = make(map[string]Service) - } - config.Services["crowdsec"] = crowdsecService - - // Marshal config with better formatting - yamlData, err := yaml.Marshal(&config) + // Read destination file + destData, err := os.ReadFile(destFile) if err != nil { - return err + return fmt.Errorf("error reading destination file: %w", err) } - // Write config back to file - return os.WriteFile(configPath, yamlData, 0644) + // Parse source YAML + var sourceYAML map[string]interface{} + if err := yaml.Unmarshal(sourceData, &sourceYAML); err != nil { + return fmt.Errorf("error parsing source YAML: %w", err) + } + + // Parse destination YAML + var destYAML map[string]interface{} + if err := yaml.Unmarshal(destData, &destYAML); err != nil { + return fmt.Errorf("error parsing destination YAML: %w", err) + } + + // Get entryPoints section from source + entryPoints, ok := sourceYAML["entryPoints"] + if !ok { + return fmt.Errorf("entryPoints section not found in source file") + } + + // Update entryPoints in destination + destYAML["entryPoints"] = entryPoints + + // Marshal updated destination YAML + updatedData, err := yaml.Marshal(destYAML) + if err != nil { + return fmt.Errorf("error marshaling updated YAML: %w", err) + } + + // Write updated YAML back to destination file + if err := os.WriteFile(destFile, updatedData, 0644); err != nil { + return fmt.Errorf("error writing to destination file: %w", err) + } + + return nil +} + +func copyWebsecureEntryPoint(sourceFile, destFile string) error { + // Read source file + sourceData, err := os.ReadFile(sourceFile) + if err != nil { + return fmt.Errorf("error reading source file: %w", err) + } + + // Read destination file + destData, err := os.ReadFile(destFile) + if err != nil { + return fmt.Errorf("error reading destination file: %w", err) + } + + // Parse source YAML + var sourceYAML map[string]interface{} + if err := yaml.Unmarshal(sourceData, &sourceYAML); err != nil { + return fmt.Errorf("error parsing source YAML: %w", err) + } + + // Parse destination YAML + var destYAML map[string]interface{} + if err := yaml.Unmarshal(destData, &destYAML); err != nil { + return fmt.Errorf("error parsing destination YAML: %w", err) + } + + // Get entryPoints section from source + entryPoints, ok := sourceYAML["entryPoints"].(map[string]interface{}) + if !ok { + return fmt.Errorf("entryPoints section not found in source file or has invalid format") + } + + // Get websecure configuration + websecure, ok := entryPoints["websecure"] + if !ok { + return fmt.Errorf("websecure entrypoint not found in source file") + } + + // Get or create entryPoints section in destination + destEntryPoints, ok := destYAML["entryPoints"].(map[string]interface{}) + if !ok { + // If entryPoints section doesn't exist, create it + destEntryPoints = make(map[string]interface{}) + destYAML["entryPoints"] = destEntryPoints + } + + // Update websecure in destination + destEntryPoints["websecure"] = websecure + + // Marshal updated destination YAML + updatedData, err := yaml.Marshal(destYAML) + if err != nil { + return fmt.Errorf("error marshaling updated YAML: %w", err) + } + + // Write updated YAML back to destination file + if err := os.WriteFile(destFile, updatedData, 0644); err != nil { + return fmt.Errorf("error writing to destination file: %w", err) + } + + return nil +} + +func copyDockerService(sourceFile, destFile, serviceName string) error { + // Read source file + sourceData, err := os.ReadFile(sourceFile) + if err != nil { + return fmt.Errorf("error reading source file: %w", err) + } + + // Read destination file + destData, err := os.ReadFile(destFile) + if err != nil { + return fmt.Errorf("error reading destination file: %w", err) + } + + // Parse source Docker Compose YAML + var sourceCompose map[string]interface{} + if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil { + return fmt.Errorf("error parsing source Docker Compose file: %w", err) + } + + // Parse destination Docker Compose YAML + var destCompose map[string]interface{} + if err := yaml.Unmarshal(destData, &destCompose); err != nil { + return fmt.Errorf("error parsing destination Docker Compose file: %w", err) + } + + // Get services section from source + sourceServices, ok := sourceCompose["services"].(map[string]interface{}) + if !ok { + return fmt.Errorf("services section not found in source file or has invalid format") + } + + // Get the specific service configuration + serviceConfig, ok := sourceServices[serviceName] + if !ok { + return fmt.Errorf("service '%s' not found in source file", serviceName) + } + + // Get or create services section in destination + destServices, ok := destCompose["services"].(map[string]interface{}) + if !ok { + // If services section doesn't exist, create it + destServices = make(map[string]interface{}) + destCompose["services"] = destServices + } + + // Update service in destination + destServices[serviceName] = serviceConfig + + // Marshal updated destination YAML + // Use yaml.v3 encoder to preserve formatting and comments + updatedData, err := yaml.Marshal(destCompose) + if err != nil { + return fmt.Errorf("error marshaling updated Docker Compose file: %w", err) + } + + // Write updated YAML back to destination file + if err := os.WriteFile(destFile, updatedData, 0644); err != nil { + return fmt.Errorf("error writing to destination file: %w", err) + } + + return nil } diff --git a/install/config/crowdsec/docker-compose.yml b/install/config/crowdsec/docker-compose.yml new file mode 100644 index 00000000..982b3335 --- /dev/null +++ b/install/config/crowdsec/docker-compose.yml @@ -0,0 +1,35 @@ +services: + crowdsec: + image: crowdsecurity/crowdsec:latest + container_name: crowdsec + environment: + GID: "1000" + COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules + ENROLL_INSTANCE_NAME: "pangolin-crowdsec" + PARSERS: crowdsecurity/whitelists + ACQUIRE_FILES: "/var/log/traefik/*.log" + ENROLL_TAGS: docker + healthcheck: + test: ["CMD", "cscli", "capi", "status"] + depends_on: + - gerbil # Wait for gerbil to be healthy + labels: + - "traefik.enable=false" # Disable traefik for crowdsec + volumes: + # crowdsec container data + - ./config/crowdsec:/etc/crowdsec # crowdsec config + - ./config/crowdsec/db:/var/lib/crowdsec/data # crowdsec db + # log bind mounts into crowdsec + - ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro # auth.log + - ./config/crowdsec_logs/syslog:/var/log/syslog:ro # syslog + - ./config/crowdsec_logs:/var/log # crowdsec logs + - ./config/traefik/logs:/var/log/traefik # traefik logs + ports: + - 9090:9090 # port mapping for local firewall bouncers + - 6060:6060 # metrics endpoint for prometheus + expose: + - 9090 # http api for bouncers + - 6060 # metrics endpoint for prometheus + - 7422 # appsec waf endpoint + restart: unless-stopped + command: -t # Add test config flag to verify configuration \ No newline at end of file diff --git a/install/config/crowdsec/traefik_config.yml b/install/config/crowdsec/traefik_config.yml index 2ac9125c..59356ea7 100644 --- a/install/config/crowdsec/traefik_config.yml +++ b/install/config/crowdsec/traefik_config.yml @@ -80,7 +80,7 @@ entryPoints: http: tls: certResolver: "letsencrypt" - middlewares: # CHANGE MADE HERE (BOUNCER ENABLED) !!! + middlewares: - crowdsec@file serversTransport: diff --git a/install/crowdsec.go b/install/crowdsec.go index 1d9bef65..2b77985f 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -15,17 +15,40 @@ func installCrowdsec(config Config) error { return fmt.Errorf("backup failed: %v", err) } - if err := AddCrowdSecService("docker-compose.yml"); err != nil { - return fmt.Errorf("crowdsec service addition failed: %v", err) - } - if err := createConfigFiles(config); err != nil { fmt.Printf("Error creating config files: %v\n", err) os.Exit(1) } - // moveFile("config/crowdsec/traefik_config.yml", "config/traefik/traefik_config.yml") - moveFile("config/crowdsec/dynamic.yml", "config/traefik/dynamic.yml") + if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { + fmt.Printf("Error copying docker service: %v\n", err) + os.Exit(1) + } + + if err := copyWebsecureEntryPoint("config/crowdsec/traefik_config.yml", "config/traefik/traefik_config.yml"); err != nil { + fmt.Printf("Error copying entry points: %v\n", err) + os.Exit(1) + } + + if err := copyEntryPoints("config/traefik/traefik_config.yml", "config/crowdsec/traefik_config.yml"); err != nil { + fmt.Printf("Error copying entry points: %v\n", err) + os.Exit(1) + } + + if err := moveFile("config/crowdsec/traefik_config.yml", "config/traefik/traefik_config.yml"); err != nil { + fmt.Printf("Error moving file: %v\n", err) + os.Exit(1) + } + + if err := moveFile("config/crowdsec/dynamic_config.yml", "config/traefik/dynamic_config.yml"); err != nil { + fmt.Printf("Error moving file: %v\n", err) + os.Exit(1) + } + + if err := os.Remove("config/crowdsec/docker-compose.yml"); err != nil { + fmt.Printf("Error removing file: %v\n", err) + os.Exit(1) + } if err := retrieveBouncerKey(config); err != nil { return fmt.Errorf("bouncer key retrieval failed: %v", err) From e6c42e9610feb7e656cc6c951fe84a0f3605c27d Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 16 Feb 2025 11:31:01 -0500 Subject: [PATCH 11/61] Indent 2 --- install/config.go | 43 ++++++++++++++++++++++++++++++++++++++++--- install/crowdsec.go | 19 ------------------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/install/config.go b/install/config.go index 1ebfbecb..b666b53e 100644 --- a/install/config.go +++ b/install/config.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "os" + "os/exec" "gopkg.in/yaml.v3" ) @@ -139,7 +140,8 @@ func copyEntryPoints(sourceFile, destFile string) error { destYAML["entryPoints"] = entryPoints // Marshal updated destination YAML - updatedData, err := yaml.Marshal(destYAML) + // updatedData, err := yaml.Marshal(destYAML) + updatedData, err := MarshalYAMLWithIndent(destYAML, 2) if err != nil { return fmt.Errorf("error marshaling updated YAML: %w", err) } @@ -201,7 +203,8 @@ func copyWebsecureEntryPoint(sourceFile, destFile string) error { destEntryPoints["websecure"] = websecure // Marshal updated destination YAML - updatedData, err := yaml.Marshal(destYAML) + // updatedData, err := yaml.Marshal(destYAML) + updatedData, err := MarshalYAMLWithIndent(destYAML, 2) if err != nil { return fmt.Errorf("error marshaling updated YAML: %w", err) } @@ -264,7 +267,8 @@ func copyDockerService(sourceFile, destFile, serviceName string) error { // Marshal updated destination YAML // Use yaml.v3 encoder to preserve formatting and comments - updatedData, err := yaml.Marshal(destCompose) + // updatedData, err := yaml.Marshal(destCompose) + updatedData, err := MarshalYAMLWithIndent(destCompose, 2) if err != nil { return fmt.Errorf("error marshaling updated Docker Compose file: %w", err) } @@ -276,3 +280,36 @@ func copyDockerService(sourceFile, destFile, serviceName string) error { return nil } + +func backupConfig() error { + // Backup docker-compose.yml + if _, err := os.Stat("docker-compose.yml"); err == nil { + if err := copyFile("docker-compose.yml", "docker-compose.yml.backup"); err != nil { + return fmt.Errorf("failed to backup docker-compose.yml: %v", err) + } + } + + // Backup config directory + if _, err := os.Stat("config"); err == nil { + cmd := exec.Command("tar", "-czvf", "config.tar.gz", "config") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to backup config directory: %v", err) + } + } + + return nil +} + +func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) { + buffer := new(bytes.Buffer) + encoder := yaml.NewEncoder(buffer) + encoder.SetIndent(indent) + + err := encoder.Encode(data) + if err != nil { + return nil, err + } + + defer encoder.Close() + return buffer.Bytes(), nil +} diff --git a/install/crowdsec.go b/install/crowdsec.go index 2b77985f..5b777e80 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -57,25 +57,6 @@ func installCrowdsec(config Config) error { return nil } -func backupConfig() error { - // Backup docker-compose.yml - if _, err := os.Stat("docker-compose.yml"); err == nil { - if err := copyFile("docker-compose.yml", "docker-compose.yml.backup"); err != nil { - return fmt.Errorf("failed to backup docker-compose.yml: %v", err) - } - } - - // Backup config directory - if _, err := os.Stat("config"); err == nil { - cmd := exec.Command("tar", "-czvf", "config.tar.gz", "config") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to backup config directory: %v", err) - } - } - - return nil -} - func retrieveBouncerKey(config Config) error { // Start crowdsec container cmd := exec.Command("docker", "compose", "up", "-d", "crowdsec") From 851bedb2e51ce0897b4268fc621eccb958fa2edb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 16 Feb 2025 17:28:10 -0500 Subject: [PATCH 12/61] refactor create and update resource endpoints --- server/routers/org/createOrg.ts | 23 +- server/routers/resource/createResource.ts | 441 ++++++++++++++-------- server/routers/resource/updateResource.ts | 390 +++++++++++-------- 3 files changed, 543 insertions(+), 311 deletions(-) diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 3c25c0c3..a6072a30 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -2,7 +2,15 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; -import { Org, orgs, roleActions, roles, userOrgs } from "@server/db/schema"; +import { + domains, + Org, + orgDomains, + orgs, + roleActions, + roles, + userOrgs +} from "@server/db/schema"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -16,7 +24,6 @@ const createOrgSchema = z .object({ orgId: z.string(), name: z.string().min(1).max(255) - // domain: z.string().min(1).max(255).optional(), }) .strict(); @@ -82,14 +89,13 @@ export async function createOrg( let org: Org | null = null; await db.transaction(async (trx) => { - const domain = config.getBaseDomain(); + const allDomains = await trx.select().from(domains); const newOrg = await trx .insert(orgs) .values({ orgId, - name, - domain + name }) .returning(); @@ -109,6 +115,13 @@ export async function createOrg( return; } + await trx.insert(orgDomains).values( + allDomains.map((domain) => ({ + orgId: newOrg[0].orgId, + domainId: domain.domainId + })) + ); + await trx.insert(userOrgs).values({ userId: req.user!.userId, orgId: newOrg[0].orgId, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 39b07a57..2cf8052e 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -1,8 +1,9 @@ -import { SqliteError } from "better-sqlite3"; import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { + domains, + orgDomains, orgs, Resource, resources, @@ -27,69 +28,17 @@ const createResourceParamsSchema = z }) .strict(); -const createResourceSchema = z +const createHttpResourceSchema = z .object({ - subdomain: z.string().optional(), name: z.string().min(1).max(255), + subdomain: subdomainSchema.optional(), + isBaseDomain: z.boolean().optional(), siteId: z.number(), http: z.boolean(), protocol: z.string(), - proxyPort: z.number().optional(), - isBaseDomain: z.boolean().optional() + domainId: z.string() }) - .refine( - (data) => { - if (!data.http) { - return z - .number() - .int() - .min(1) - .max(65535) - .safeParse(data.proxyPort).success; - } - return true; - }, - { - message: "Invalid port number", - path: ["proxyPort"] - } - ) - .refine( - (data) => { - if (data.http && !data.isBaseDomain) { - return subdomainSchema.safeParse(data.subdomain).success; - } - return true; - }, - { - message: "Invalid subdomain", - path: ["subdomain"] - } - ) - .refine( - (data) => { - if (!config.getRawConfig().flags?.allow_raw_resources) { - if (data.proxyPort !== undefined) { - return false; - } - } - return true; - }, - { - message: "Proxy port cannot be set" - } - ) - // .refine( - // (data) => { - // if (data.proxyPort === 443 || data.proxyPort === 80) { - // return false; - // } - // return true; - // }, - // { - // message: "Port 80 and 443 are reserved for http and https resources" - // } - // ) + .strict() .refine( (data) => { if (!config.getRawConfig().flags?.allow_base_domain_resources) { @@ -104,6 +53,29 @@ const createResourceSchema = z } ); +const createRawResourceSchema = z + .object({ + name: z.string().min(1).max(255), + siteId: z.number(), + http: z.boolean(), + protocol: z.string(), + proxyPort: z.number().int().min(1).max(65535) + }) + .strict() + .refine( + (data) => { + if (!config.getRawConfig().flags?.allow_raw_resources) { + if (data.proxyPort !== undefined) { + return false; + } + } + return true; + }, + { + message: "Proxy port cannot be set" + } + ); + export type CreateResourceResponse = Resource; export async function createResource( @@ -112,18 +84,6 @@ export async function createResource( next: NextFunction ): Promise { try { - const parsedBody = createResourceSchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data; - // Validate request params const parsedParams = createResourceParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -159,99 +119,25 @@ export async function createResource( ); } - let fullDomain = ""; - if (isBaseDomain) { - fullDomain = org[0].domain; - } else { - fullDomain = `${subdomain}.${org[0].domain}`; + if (!req.body?.http) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "http field is required") + ); } - // if http is false check to see if there is already a resource with the same port and protocol - if (!http) { - const existingResource = await db - .select() - .from(resources) - .where( - and( - eq(resources.protocol, protocol), - eq(resources.proxyPort, proxyPort!) - ) - ); + const { http } = req.body; - if (existingResource.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that protocol and port already exists" - ) - ); - } + if (http) { + return await createHttpResource( + { req, res, next }, + { siteId, orgId } + ); } else { - // make sure the full domain is unique - const existingResource = await db - .select() - .from(resources) - .where(eq(resources.fullDomain, fullDomain)); - - if (existingResource.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that domain already exists" - ) - ); - } + return await createRawResource( + { req, res, next }, + { siteId, orgId } + ); } - - await db.transaction(async (trx) => { - const newResource = await trx - .insert(resources) - .values({ - siteId, - fullDomain: http ? fullDomain : null, - orgId, - name, - subdomain, - http, - protocol, - proxyPort, - ssl: true, - isBaseDomain - }) - .returning(); - - const adminRole = await db - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); - - if (adminRole.length === 0) { - return next( - createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) - ); - } - - await trx.insert(roleResources).values({ - roleId: adminRole[0].roleId, - resourceId: newResource[0].resourceId - }); - - if (req.userOrgRoleId != adminRole[0].roleId) { - // make sure the user can access the resource - await trx.insert(userResources).values({ - userId: req.user?.userId!, - resourceId: newResource[0].resourceId - }); - } - response(res, { - data: newResource[0], - success: true, - error: false, - message: "Resource created successfully", - status: HttpCode.CREATED - }); - }); } catch (error) { logger.error(error); return next( @@ -259,3 +145,242 @@ export async function createResource( ); } } + +async function createHttpResource( + route: { + req: Request; + res: Response; + next: NextFunction; + }, + meta: { + siteId: number; + orgId: string; + } +) { + const { req, res, next } = route; + const { siteId, orgId } = meta; + + const parsedBody = createHttpResourceSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name, subdomain, isBaseDomain, http, protocol, domainId } = + parsedBody.data; + + const [orgDomain] = await db + .select() + .from(orgDomains) + .where( + and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId)) + ) + .leftJoin(domains, eq(orgDomains.domainId, domains.domainId)); + + if (!orgDomain || !orgDomain.domains) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${parsedBody.data.domainId} not found` + ) + ); + } + + const domain = orgDomain.domains; + + let fullDomain = ""; + if (isBaseDomain) { + fullDomain = domain.baseDomain; + } else { + fullDomain = `${subdomain}.${domain.baseDomain}`; + } + + // make sure the full domain is unique + const existingResource = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + + let resource: Resource | undefined; + + await db.transaction(async (trx) => { + const newResource = await trx + .insert(resources) + .values({ + siteId, + fullDomain: http ? fullDomain : null, + orgId, + name, + subdomain, + http, + protocol, + ssl: true, + isBaseDomain + }) + .returning(); + + const adminRole = await db + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) + ); + } + + await trx.insert(roleResources).values({ + roleId: adminRole[0].roleId, + resourceId: newResource[0].resourceId + }); + + if (req.userOrgRoleId != adminRole[0].roleId) { + // make sure the user can access the resource + await trx.insert(userResources).values({ + userId: req.user?.userId!, + resourceId: newResource[0].resourceId + }); + } + + resource = newResource[0]; + }); + + if (!resource) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create resource" + ) + ); + } + + return response(res, { + data: resource, + success: true, + error: false, + message: "Http resource created successfully", + status: HttpCode.CREATED + }); +} + +async function createRawResource( + route: { + req: Request; + res: Response; + next: NextFunction; + }, + meta: { + siteId: number; + orgId: string; + } +) { + const { req, res, next } = route; + const { siteId, orgId } = meta; + + const parsedBody = createRawResourceSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name, http, protocol, proxyPort } = parsedBody.data; + + // if http is false check to see if there is already a resource with the same port and protocol + const existingResource = await db + .select() + .from(resources) + .where( + and( + eq(resources.protocol, protocol), + eq(resources.proxyPort, proxyPort!) + ) + ); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that protocol and port already exists" + ) + ); + } + + let resource: Resource | undefined; + + await db.transaction(async (trx) => { + const newResource = await trx + .insert(resources) + .values({ + siteId, + orgId, + name, + http, + protocol, + proxyPort + }) + .returning(); + + const adminRole = await db + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) + ); + } + + await trx.insert(roleResources).values({ + roleId: adminRole[0].roleId, + resourceId: newResource[0].resourceId + }); + + if (req.userOrgRoleId != adminRole[0].roleId) { + // make sure the user can access the resource + await trx.insert(userResources).values({ + userId: req.user?.userId!, + resourceId: newResource[0].resourceId + }); + } + + resource = newResource[0]; + }); + + if (!resource) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create resource" + ) + ); + } + + return response(res, { + data: resource, + success: true, + error: false, + message: "Non-http resource created successfully", + status: HttpCode.CREATED + }); +} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index e464b4c5..8d737541 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -1,8 +1,15 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs, resources, sites } from "@server/db/schema"; -import { eq, or, and } from "drizzle-orm"; +import { + domains, + Org, + orgDomains, + orgs, + Resource, + resources +} from "@server/db/schema"; +import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -20,17 +27,40 @@ const updateResourceParamsSchema = z }) .strict(); -const updateResourceBodySchema = z +const updateHttpResourceBodySchema = z .object({ name: z.string().min(1).max(255).optional(), subdomain: subdomainSchema.optional(), ssl: z.boolean().optional(), sso: z.boolean().optional(), blockAccess: z.boolean().optional(), - proxyPort: z.number().int().min(1).max(65535).optional(), emailWhitelistEnabled: z.boolean().optional(), isBaseDomain: z.boolean().optional(), applyRules: z.boolean().optional(), + domainId: z.string().optional() + }) + .strict() + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided for update" + }) + .refine( + (data) => { + if (!config.getRawConfig().flags?.allow_base_domain_resources) { + if (data.isBaseDomain) { + return false; + } + } + return true; + }, + { + message: "Base domain resources are not allowed" + } + ); + +const updateRawResourceBodySchema = z + .object({ + name: z.string().min(1).max(255).optional(), + proxyPort: z.number().int().min(1).max(65535).optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -46,30 +76,6 @@ const updateResourceBodySchema = z return true; }, { message: "Cannot update proxyPort" } - ) - // .refine( - // (data) => { - // if (data.proxyPort === 443 || data.proxyPort === 80) { - // return false; - // } - // return true; - // }, - // { - // message: "Port 80 and 443 are reserved for http and https resources" - // } - // ) - .refine( - (data) => { - if (!config.getRawConfig().flags?.allow_base_domain_resources) { - if (data.isBaseDomain) { - return false; - } - } - return true; - }, - { - message: "Base domain resources are not allowed" - } ); export async function updateResource( @@ -88,18 +94,7 @@ export async function updateResource( ); } - const parsedBody = updateResourceBodySchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - const { resourceId } = parsedParams.data; - const updateData = parsedBody.data; const [result] = await db .select() @@ -119,117 +114,33 @@ export async function updateResource( ); } - if (updateData.subdomain) { - if (!resource.http) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Cannot update subdomain for non-http resource" - ) - ); - } - - const valid = subdomainSchema.safeParse( - updateData.subdomain - ).success; - if (!valid) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid subdomain provided" - ) - ); - } - } - - if (updateData.proxyPort) { - const proxyPort = updateData.proxyPort; - const existingResource = await db - .select() - .from(resources) - .where( - and( - eq(resources.protocol, resource.protocol), - eq(resources.proxyPort, proxyPort!) - ) - ); - - if ( - existingResource.length > 0 && - existingResource[0].resourceId !== resourceId - ) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that protocol and port already exists" - ) - ); - } - } - - if (!org?.domain) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Resource does not have a domain" - ) + if (resource.http) { + // HANDLE UPDATING HTTP RESOURCES + return await updateHttpResource( + { + req, + res, + next + }, + { + resource, + org + } + ); + } else { + // HANDLE UPDATING RAW TCP/UDP RESOURCES + return await updateRawResource( + { + req, + res, + next + }, + { + resource, + org + } ); } - - let fullDomain: string | undefined; - if (updateData.isBaseDomain) { - fullDomain = org.domain; - } else if (updateData.subdomain) { - fullDomain = `${updateData.subdomain}.${org.domain}`; - } - - const updatePayload = { - ...updateData, - ...(fullDomain && { fullDomain }) - }; - - if ( - fullDomain && - (updatePayload.subdomain !== undefined || - updatePayload.isBaseDomain !== undefined) - ) { - const [existingDomain] = await db - .select() - .from(resources) - .where(eq(resources.fullDomain, fullDomain)); - - if (existingDomain && existingDomain.resourceId !== resourceId) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that domain already exists" - ) - ); - } - } - - const updatedResource = await db - .update(resources) - .set(updatePayload) - .where(eq(resources.resourceId, resourceId)) - .returning(); - - if (updatedResource.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); - } - - return response(res, { - data: updatedResource[0], - success: true, - error: false, - message: "Resource updated successfully", - status: HttpCode.OK - }); } catch (error) { logger.error(error); return next( @@ -237,3 +148,186 @@ export async function updateResource( ); } } + +async function updateHttpResource( + route: { + req: Request; + res: Response; + next: NextFunction; + }, + meta: { + resource: Resource; + org: Org; + } +) { + const { next, req, res } = route; + const { resource, org } = meta; + + const parsedBody = updateHttpResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const updateData = parsedBody.data; + + if (updateData.domainId) { + const [existingDomain] = await db + .select() + .from(orgDomains) + .where( + and( + eq(orgDomains.orgId, org.orgId), + eq(orgDomains.domainId, updateData.domainId) + ) + ) + .leftJoin(domains, eq(orgDomains.domainId, domains.domainId)); + + if (!existingDomain) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Domain not found`) + ); + } + } + + const domainId = updateData.domainId || resource.domainId!; + const subdomain = updateData.subdomain || resource.subdomain; + + const [domain] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)); + + let fullDomain: string | null = null; + if (updateData.isBaseDomain) { + fullDomain = domain.baseDomain; + } else if (subdomain && domain) { + fullDomain = `${subdomain}.${domain}`; + } + + if (fullDomain) { + const [existingDomain] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, fullDomain)); + + if ( + existingDomain && + existingDomain.resourceId !== resource.resourceId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + } + + const updatePayload = { + ...updateData, + fullDomain + }; + + const updatedResource = await db + .update(resources) + .set(updatePayload) + .where(eq(resources.resourceId, resource.resourceId)) + .returning(); + + if (updatedResource.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resource.resourceId} not found` + ) + ); + } + + return response(res, { + data: updatedResource[0], + success: true, + error: false, + message: "HTTP resource updated successfully", + status: HttpCode.OK + }); +} + +async function updateRawResource( + route: { + req: Request; + res: Response; + next: NextFunction; + }, + meta: { + resource: Resource; + org: Org; + } +) { + const { next, req, res } = route; + const { resource } = meta; + + const parsedBody = updateRawResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const updateData = parsedBody.data; + + if (updateData.proxyPort) { + const proxyPort = updateData.proxyPort; + const existingResource = await db + .select() + .from(resources) + .where( + and( + eq(resources.protocol, resource.protocol), + eq(resources.proxyPort, proxyPort!) + ) + ); + + if ( + existingResource.length > 0 && + existingResource[0].resourceId !== resource.resourceId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that protocol and port already exists" + ) + ); + } + } + + const updatedResource = await db + .update(resources) + .set(updateData) + .where(eq(resources.resourceId, resource.resourceId)) + .returning(); + + if (updatedResource.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resource.resourceId} not found` + ) + ); + } + + return response(res, { + data: updatedResource[0], + success: true, + error: false, + message: "Non-http Resource updated successfully", + status: HttpCode.OK + }); +} From 82f990eb8b48ed60ac6809cb797bc7d842e91b44 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 16 Feb 2025 18:09:17 -0500 Subject: [PATCH 13/61] add list domains for org endpoint --- server/auth/actions.ts | 1 + server/db/schema.ts | 1 + server/routers/domain/index.ts | 1 + server/routers/domain/listDomains.ts | 108 +++++++++++++++++++++ server/routers/external.ts | 8 ++ server/routers/org/createOrg.ts | 5 +- server/routers/traefik/getTraefikConfig.ts | 19 ++-- 7 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 server/routers/domain/index.ts create mode 100644 server/routers/domain/listDomains.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 001b9a6c..4510c55b 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -62,6 +62,7 @@ export enum ActionsEnum { deleteResourceRule = "deleteResourceRule", listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", + listOrgDomains = "listOrgDomains", } export async function checkUserActionPermission( diff --git a/server/db/schema.ts b/server/db/schema.ts index 6cbe0fc5..33d979e7 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -438,3 +438,4 @@ export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; +export type Domain = InferSelectModel; diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts new file mode 100644 index 00000000..2233b069 --- /dev/null +++ b/server/routers/domain/index.ts @@ -0,0 +1 @@ +export * from "./listDomains"; diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts new file mode 100644 index 00000000..a5140a31 --- /dev/null +++ b/server/routers/domain/listDomains.ts @@ -0,0 +1,108 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { domains, orgDomains, users } from "@server/db/schema"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const listDomainsParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const listDomainsSchema = z + .object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) + }) + .strict(); + +async function queryDomains(orgId: string, limit: number, offset: number) { + return await db + .select({ + domainId: domains.domainId, + baseDomain: domains.baseDomain + }) + .from(orgDomains) + .where(eq(orgDomains.orgId, orgId)) + .leftJoin(domains, eq(domains.domainId, orgDomains.domainId)) + .limit(limit) + .offset(offset); +} + +export type ListDomainsResponse = { + domains: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listDomains( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listDomainsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listDomainsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const domains = await queryDomains(orgId.toString(), limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(users); + + return response(res, { + data: { + domains, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Users 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/external.ts b/server/routers/external.ts index 19c57008..f22fb281 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -3,6 +3,7 @@ import config from "@server/lib/config"; import * as site from "./site"; import * as org from "./org"; import * as resource from "./resource"; +import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; import * as auth from "./auth"; @@ -133,6 +134,13 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/org/:orgId/domains", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listOrgDomains), + domain.listDomains +); + authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index a6072a30..381ce20e 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -89,7 +89,10 @@ export async function createOrg( let org: Org | null = null; await db.transaction(async (trx) => { - const allDomains = await trx.select().from(domains); + const allDomains = await trx + .select() + .from(domains) + .where(eq(domains.configManaged, true)); const newOrg = await trx .insert(orgs) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 29a4d2c0..55e0e290 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -26,6 +26,7 @@ export async function traefikConfigProvider( proxyPort: resources.proxyPort, protocol: resources.protocol, isBaseDomain: resources.isBaseDomain, + domainId: resources.domainId, // Site fields site: { siteId: sites.siteId, @@ -34,8 +35,7 @@ export async function traefikConfigProvider( }, // Org fields org: { - orgId: orgs.orgId, - domain: orgs.domain + orgId: orgs.orgId }, // Targets as a subquery targets: sql`json_group_array(json_object( @@ -105,15 +105,22 @@ export async function traefikConfigProvider( const site = resource.site; const org = resource.org; - if (!org.domain) { - continue; - } - const routerName = `${resource.resourceId}-router`; const serviceName = `${resource.resourceId}-service`; const fullDomain = `${resource.fullDomain}`; if (resource.http) { + if (!resource.domainId) { + continue; + } + + if (!resource.fullDomain) { + logger.error( + `Resource ${resource.resourceId} has no fullDomain` + ); + continue; + } + // HTTP configuration remains the same if (!resource.subdomain && !resource.isBaseDomain) { continue; From fd11fb81d6eb839c0ac401afd9877405e1824d83 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 18 Feb 2025 21:41:23 -0500 Subject: [PATCH 14/61] Remove some config --- install/config.go | 20 +++++ install/config/crowdsec/config.yaml | 12 --- install/config/crowdsec/dynamic_config.yml | 4 +- .../crowdsec/local_api_credentials.yaml | 2 - install/crowdsec.go | 26 +++++- install/main.go | 79 +++++++++++++++++-- 6 files changed, 121 insertions(+), 22 deletions(-) delete mode 100644 install/config/crowdsec/config.yaml delete mode 100644 install/config/crowdsec/local_api_credentials.yaml diff --git a/install/config.go b/install/config.go index b666b53e..f87bb1af 100644 --- a/install/config.go +++ b/install/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "strings" "gopkg.in/yaml.v3" ) @@ -313,3 +314,22 @@ func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) { defer encoder.Close() return buffer.Bytes(), nil } + +func replaceInFile(filepath, oldStr, newStr string) error { + // Read the file content + content, err := os.ReadFile(filepath) + if err != nil { + return fmt.Errorf("error reading file: %v", err) + } + + // Replace the string + newContent := strings.Replace(string(content), oldStr, newStr, -1) + + // Write the modified content back to the file + err = os.WriteFile(filepath, []byte(newContent), 0644) + if err != nil { + return fmt.Errorf("error writing file: %v", err) + } + + return nil +} diff --git a/install/config/crowdsec/config.yaml b/install/config/crowdsec/config.yaml deleted file mode 100644 index 0acf4635..00000000 --- a/install/config/crowdsec/config.yaml +++ /dev/null @@ -1,12 +0,0 @@ -api: - client: - insecure_skip_verify: false - credentials_path: /etc/crowdsec/local_api_credentials.yaml - server: - log_level: info - listen_uri: 0.0.0.0:9090 - profiles_path: /etc/crowdsec/profiles.yaml - trusted_ips: - - 0.0.0.0/0 - - 127.0.0.1 - - ::1 \ No newline at end of file diff --git a/install/config/crowdsec/dynamic_config.yml b/install/config/crowdsec/dynamic_config.yml index d2556971..a3d32dbd 100644 --- a/install/config/crowdsec/dynamic_config.yml +++ b/install/config/crowdsec/dynamic_config.yml @@ -42,8 +42,8 @@ http: crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later crowdsecAppsecFailureBlock: true # Block on failure crowdsecAppsecUnreachableBlock: true # Block on unreachable - crowdsecLapiKey: "{{.TraefikBouncerKey}}" # CrowdSec API key which you noted down later - crowdsecLapiHost: crowdsec:9090 # CrowdSec + crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later + crowdsecLapiHost: crowdsec:8080 # CrowdSec crowdsecLapiScheme: http # CrowdSec API scheme forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs - "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE) diff --git a/install/config/crowdsec/local_api_credentials.yaml b/install/config/crowdsec/local_api_credentials.yaml deleted file mode 100644 index 8776e4fd..00000000 --- a/install/config/crowdsec/local_api_credentials.yaml +++ /dev/null @@ -1,2 +0,0 @@ -url: http://0.0.0.0:9090 -login: localhost \ No newline at end of file diff --git a/install/crowdsec.go b/install/crowdsec.go index 5b777e80..6d25a633 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -10,6 +10,11 @@ import ( ) func installCrowdsec(config Config) error { + + if err := stopContainers(); err != nil { + return fmt.Errorf("failed to stop containers: %v", err) + } + // Run installation steps if err := backupConfig(); err != nil { return fmt.Errorf("backup failed: %v", err) @@ -20,6 +25,10 @@ func installCrowdsec(config Config) error { os.Exit(1) } + os.MkdirAll("config/crowdsec/db", 0755) + os.MkdirAll("config/crowdsec_logs/syslog", 0755) + os.MkdirAll("config/traefik/logs", 0755) + if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { fmt.Printf("Error copying docker service: %v\n", err) os.Exit(1) @@ -54,16 +63,22 @@ func installCrowdsec(config Config) error { return fmt.Errorf("bouncer key retrieval failed: %v", err) } + // if err := startContainers(); err != nil { + // return fmt.Errorf("failed to start containers: %v", err) + // } + return nil } func retrieveBouncerKey(config Config) error { + + fmt.Println("Retrieving bouncer key. Please be patient...") + // Start crowdsec container cmd := exec.Command("docker", "compose", "up", "-d", "crowdsec") if err := cmd.Run(); err != nil { return fmt.Errorf("failed to start crowdsec: %v", err) } - defer exec.Command("docker", "compose", "down").Run() // verify that the container is running if not keep waiting for 10 more seconds then return an error count := 0 @@ -95,10 +110,19 @@ func retrieveBouncerKey(config Config) error { for _, line := range lines { if strings.Contains(line, "key:") { config.TraefikBouncerKey = strings.TrimSpace(strings.Split(line, ":")[1]) + fmt.Println("Bouncer key:", config.TraefikBouncerKey) break } } + // Stop crowdsec container + cmd = exec.Command("docker", "compose", "down") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to stop crowdsec: %v", err) + } + + fmt.Println("Bouncer key retrieved successfully.") + return nil } diff --git a/install/main.go b/install/main.go index e5ad98b9..bca19100 100644 --- a/install/main.go +++ b/install/main.go @@ -179,11 +179,6 @@ func readInt(reader *bufio.Reader, prompt string, defaultValue int) int { return value } -func isDockerFilePresent() bool { - _, err := os.Stat("docker-compose.yml") - return !os.IsNotExist(err) -} - func collectUserInput(reader *bufio.Reader) Config { config := Config{} @@ -521,6 +516,80 @@ func pullAndStartContainers() error { return nil } +// bring containers down +func stopContainers() error { + fmt.Println("Stopping containers...") + + // Check which docker compose command is available + var useNewStyle bool + checkCmd := exec.Command("docker", "compose", "version") + if err := checkCmd.Run(); err == nil { + useNewStyle = true + } else { + // Check if docker-compose (old style) is available + checkCmd = exec.Command("docker-compose", "version") + if err := checkCmd.Run(); err != nil { + return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) + } + } + + // Helper function to execute docker compose commands + executeCommand := func(args ...string) error { + var cmd *exec.Cmd + if useNewStyle { + cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + } else { + cmd = exec.Command("docker-compose", args...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + if err := executeCommand("-f", "docker-compose.yml", "down"); err != nil { + return fmt.Errorf("failed to stop containers: %v", err) + } + + return nil +} + +// just start containers +func startContainers() error { + fmt.Println("Starting containers...") + + // Check which docker compose command is available + var useNewStyle bool + checkCmd := exec.Command("docker", "compose", "version") + if err := checkCmd.Run(); err == nil { + useNewStyle = true + } else { + // Check if docker-compose (old style) is available + checkCmd = exec.Command("docker-compose", "version") + if err := checkCmd.Run(); err != nil { + return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) + } + } + + // Helper function to execute docker compose commands + executeCommand := func(args ...string) error { + var cmd *exec.Cmd + if useNewStyle { + cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + } else { + cmd = exec.Command("docker-compose", args...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil { + return fmt.Errorf("failed to start containers: %v", err) + } + + return nil +} + func copyFile(src, dst string) error { source, err := os.Open(src) if err != nil { From e49fb646b0a000842c51e6d5ba9cd2f2e7103d6f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 18 Feb 2025 22:56:46 -0500 Subject: [PATCH 15/61] refactor subdomain inputs --- server/routers/domain/listDomains.ts | 5 +- server/routers/resource/createResource.ts | 15 +- server/routers/resource/updateResource.ts | 11 +- server/setup/copyInConfig.ts | 2 +- .../settings/resources/CreateResourceForm.tsx | 196 ++++++++++----- .../[resourceId]/CustomDomainInput.tsx | 79 +++++- .../[resourceId]/ResourceInfoBox.tsx | 12 +- .../resources/[resourceId]/general/page.tsx | 228 +++++++++++++----- 8 files changed, 404 insertions(+), 144 deletions(-) diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index a5140a31..c44274ed 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -33,16 +33,17 @@ const listDomainsSchema = z .strict(); async function queryDomains(orgId: string, limit: number, offset: number) { - return await db + const res = await db .select({ domainId: domains.domainId, baseDomain: domains.baseDomain }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) - .leftJoin(domains, eq(domains.domainId, orgDomains.domainId)) + .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) .limit(limit) .offset(offset); + return res; } export type ListDomainsResponse = { diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 2cf8052e..3bc7c05c 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -31,7 +31,7 @@ const createResourceParamsSchema = z const createHttpResourceSchema = z .object({ name: z.string().min(1).max(255), - subdomain: subdomainSchema.optional(), + subdomain: z.string().optional(), isBaseDomain: z.boolean().optional(), siteId: z.number(), http: z.boolean(), @@ -39,6 +39,15 @@ const createHttpResourceSchema = z domainId: z.string() }) .strict() + .refine( + (data) => { + if (data.subdomain) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { message: "Invalid subdomain" } + ) .refine( (data) => { if (!config.getRawConfig().flags?.allow_base_domain_resources) { @@ -199,6 +208,8 @@ async function createHttpResource( fullDomain = `${subdomain}.${domain.baseDomain}`; } + logger.debug(`Full domain: ${fullDomain}`); + // make sure the full domain is unique const existingResource = await db .select() @@ -221,7 +232,7 @@ async function createHttpResource( .insert(resources) .values({ siteId, - fullDomain: http ? fullDomain : null, + fullDomain, orgId, name, subdomain, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 8d737541..46c36542 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -43,6 +43,15 @@ const updateHttpResourceBodySchema = z .refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided for update" }) + .refine( + (data) => { + if (data.subdomain) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { message: "Invalid subdomain" } + ) .refine( (data) => { if (!config.getRawConfig().flags?.allow_base_domain_resources) { @@ -206,7 +215,7 @@ async function updateHttpResource( if (updateData.isBaseDomain) { fullDomain = domain.baseDomain; } else if (subdomain && domain) { - fullDomain = `${subdomain}.${domain}`; + fullDomain = `${subdomain}.${domain.baseDomain}`; } if (fullDomain) { diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index 88d7bcdc..d1860677 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -69,7 +69,7 @@ export async function copyInConfig() { if (resource.isBaseDomain) { fullDomain = domain.baseDomain; } else { - fullDomain = `${resource.subdomain}.${domain}`; + fullDomain = `${resource.subdomain}.${domain.baseDomain}`; } await trx diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index d27f8831..6adf8003 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -65,10 +65,12 @@ import { SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; +import { ListDomainsResponse } from "@server/routers/domain"; const createResourceFormSchema = z .object({ subdomain: z.string().optional(), + domainId: z.string().min(1).optional(), name: z.string().min(1).max(255), siteId: z.number(), http: z.boolean(), @@ -129,7 +131,9 @@ export default function CreateResourceForm({ const { env } = useEnvContext(); const [sites, setSites] = useState([]); - const [domainSuffix, setDomainSuffix] = useState(org.org.domain); + const [baseDomains, setBaseDomains] = useState< + { domainId: string; baseDomain: string }[] + >([]); const [showSnippets, setShowSnippets] = useState(false); const [resourceId, setResourceId] = useState(null); const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( @@ -140,6 +144,7 @@ export default function CreateResourceForm({ resolver: zodResolver(createResourceFormSchema), defaultValues: { subdomain: "", + domainId: "", name: "", http: true, protocol: "tcp" @@ -161,17 +166,55 @@ export default function CreateResourceForm({ reset(); const fetchSites = async () => { - const res = await api.get>( - `/org/${orgId}/sites/` - ); - setSites(res.data.data.sites); + const res = await api + .get>(`/org/${orgId}/sites/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching sites", + description: formatAxiosError( + e, + "An error occurred when fetching the sites" + ) + }); + }); - if (res.data.data.sites.length > 0) { - form.setValue("siteId", res.data.data.sites[0].siteId); + if (res?.status === 200) { + setSites(res.data.data.sites); + + if (res.data.data.sites.length > 0) { + form.setValue("siteId", res.data.data.sites[0].siteId); + } + } + }; + + const fetchDomains = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/domains/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching domains", + description: formatAxiosError( + e, + "An error occurred when fetching the domains" + ) + }); + }); + + if (res?.status === 200) { + const domains = res.data.data.domains; + setBaseDomains(domains); + if (domains.length) { + form.setValue("domainId", domains[0].domainId); + } } }; fetchSites(); + fetchDomains(); }, [open]); async function onSubmit(data: CreateResourceFormValues) { @@ -181,6 +224,7 @@ export default function CreateResourceForm({ { name: data.name, subdomain: data.http ? data.subdomain : undefined, + domainId: data.http ? data.domainId : undefined, http: data.http, protocol: data.protocol, proxyPort: data.http ? undefined : data.proxyPort, @@ -278,7 +322,7 @@ export default function CreateResourceForm({ Toggle if this is an HTTP resource or a - raw TCP/UDP resource + raw TCP/UDP resource. @@ -335,60 +379,98 @@ export default function CreateResourceForm({ )} {form.watch("http") && ( - ( - - {!env.flags - .allowBaseDomainResources && ( - - Subdomain - + <> + {domainType === "subdomain" ? ( + ( + + {!env.flags + .allowBaseDomainResources && ( + + Subdomain + + )} + {domainType === + "subdomain" && ( + + { + form.setValue( + "subdomain", + value + ); + form.setValue( + "domainId", + selectedDomainId + ); + }} + /> + + )} + + )} - {domainType === - "subdomain" ? ( - - + ) : ( + ( + + - + > + + + + + + + {baseDomains.map( + ( + option + ) => ( + + { + option.baseDomain + } + + ) + )} + + + + )} - - This is the fully - qualified domain name - that will be used to - access the resource. - - - + /> )} - /> + )} {!form.watch("http") && ( diff --git a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx index fd754dde..0764d740 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx @@ -2,27 +2,68 @@ import * as React from "react"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; + +interface DomainOption { + baseDomain: string; + domainId: string; +} interface CustomDomainInputProps { - domainSuffix: string; + domainOptions: DomainOption[]; + selectedDomainId?: string; placeholder?: string; value: string; - onChange?: (value: string) => void; + onChange?: (value: string, selectedDomainId: string) => void; } export default function CustomDomainInput({ - domainSuffix, - placeholder = "Enter subdomain", + domainOptions, + selectedDomainId, + placeholder = "Subdomain", value: defaultValue, onChange }: CustomDomainInputProps) { const [value, setValue] = React.useState(defaultValue); + const [selectedDomain, setSelectedDomain] = React.useState(); - const handleChange = (event: React.ChangeEvent) => { + React.useEffect(() => { + if (domainOptions.length) { + if (selectedDomainId) { + const selectedDomainOption = domainOptions.find( + (option) => option.domainId === selectedDomainId + ); + setSelectedDomain(selectedDomainOption || domainOptions[0]); + } else { + setSelectedDomain(domainOptions[0]); + } + } + }, [domainOptions]); + + const handleInputChange = (event: React.ChangeEvent) => { + if (!selectedDomain) { + return; + } const newValue = event.target.value; setValue(newValue); if (onChange) { - onChange(newValue); + onChange(newValue, selectedDomain.domainId); + } + }; + + const handleDomainChange = (domainId: string) => { + const newSelectedDomain = + domainOptions.find((option) => option.domainId === domainId) || + domainOptions[0]; + setSelectedDomain(newSelectedDomain); + if (onChange) { + onChange(value, newSelectedDomain.domainId); } }; @@ -33,12 +74,28 @@ export default function CustomDomainInput({ type="text" placeholder={placeholder} value={value} - onChange={handleChange} - className="rounded-r-none w-full" + onChange={handleInputChange} + className="w-1/2 mr-1 text-right" /> -
- .{domainSuffix} -
+ ); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index 38f3c84d..ab135db7 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -1,9 +1,7 @@ "use client"; -import { useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react"; -import { useOrgContext } from "@app/hooks/useOrgContext"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { Separator } from "@app/components/ui/separator"; import CopyToClipboard from "@app/components/CopyToClipboard"; @@ -17,17 +15,9 @@ import { type ResourceInfoBoxType = {}; export default function ResourceInfoBox({}: ResourceInfoBoxType) { - const [copied, setCopied] = useState(false); - - const { org } = useOrgContext(); const { resource, authInfo } = useResourceContext(); - let fullUrl = `${resource.ssl ? "https" : "http"}://`; - if (resource.isBaseDomain) { - fullUrl = fullUrl + org.org.domain; - } else { - fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`; - } + let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; return ( diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 301354a3..6bcc3fde 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -33,7 +33,6 @@ import { useEffect, useState } from "react"; import { AxiosResponse } from "axios"; import { useParams, useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; -import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { toast } from "@app/hooks/useToast"; import { SettingsContainer, @@ -53,6 +52,14 @@ import { subdomainSchema } from "@server/lib/schemas"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; +import { ListDomainsResponse } from "@server/routers/domain"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; const GeneralFormSchema = z .object({ @@ -60,7 +67,8 @@ const GeneralFormSchema = z name: z.string().min(1).max(255), proxyPort: z.number().optional(), http: z.boolean(), - isBaseDomain: z.boolean().optional() + isBaseDomain: z.boolean().optional(), + domainId: z.string().optional() }) .refine( (data) => { @@ -113,9 +121,11 @@ export default function GeneralForm() { const [sites, setSites] = useState([]); const [saveLoading, setSaveLoading] = useState(false); - const [domainSuffix, setDomainSuffix] = useState(org.org.domain); const [transferLoading, setTransferLoading] = useState(false); const [open, setOpen] = useState(false); + const [baseDomains, setBaseDomains] = useState< + ListDomainsResponse["domains"] + >([]); const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( resource.isBaseDomain ? "basedomain" : "subdomain" @@ -128,7 +138,8 @@ export default function GeneralForm() { subdomain: resource.subdomain ? resource.subdomain : undefined, proxyPort: resource.proxyPort ? resource.proxyPort : undefined, http: resource.http, - isBaseDomain: resource.isBaseDomain ? true : false + isBaseDomain: resource.isBaseDomain ? true : false, + domainId: resource.domainId || undefined }, mode: "onChange" }); @@ -147,6 +158,30 @@ export default function GeneralForm() { ); setSites(res.data.data.sites); }; + + const fetchDomains = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/domains/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching domains", + description: formatAxiosError( + e, + "An error occurred when fetching the domains" + ) + }); + }); + + if (res?.status === 200) { + const domains = res.data.data.domains; + setBaseDomains(domains); + } + }; + + fetchDomains(); fetchSites(); }, []); @@ -158,7 +193,8 @@ export default function GeneralForm() { name: data.name, subdomain: data.subdomain, proxyPort: data.proxyPort, - isBaseDomain: data.isBaseDomain + isBaseDomain: data.isBaseDomain, + domainId: data.domainId }) .catch((e) => { toast({ @@ -292,60 +328,134 @@ export default function GeneralForm() { )} - ( - - {!env.flags - .allowBaseDomainResources && ( - - Subdomain - - )} - - {domainType === - "subdomain" ? ( - - - form.setValue( - "subdomain", - value + {domainType === "subdomain" ? ( +
+ {!env.flags + .allowBaseDomainResources && ( + + Subdomain + + )} +
+
+ ( + + + + )} + /> +
+
+ ( + + + + + )} + /> +
+
+
+ ) : ( + ( + + -
- )} - - This is the subdomain - that will be used to - access the resource. - - -
- )} - /> + )} + + + + + )} + /> + )} )} @@ -427,7 +537,7 @@ export default function GeneralForm() { control={transferForm.control} name="siteId" render={({ field }) => ( - + Destination Site From 3194dc56eb990cac131a070c148538cc68d944e2 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 19 Feb 2025 09:44:40 -0500 Subject: [PATCH 16/61] Move path to first in dropdown --- .../settings/resources/[resourceId]/rules/page.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 1b9eb6ca..a3fb033d 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -92,9 +92,9 @@ enum RuleAction { } enum RuleMatch { + PATH = "Path", IP = "IP", CIDR = "IP Range", - PATH = "Path" } export default function ResourceRules(props: { @@ -469,9 +469,9 @@ export default function ResourceRules(props: { + {RuleMatch.PATH} {RuleMatch.IP} {RuleMatch.CIDR} - {RuleMatch.PATH} ) @@ -665,17 +665,17 @@ export default function ResourceRules(props: { + {resource.http && ( + + {RuleMatch.PATH} + + )} {RuleMatch.IP} {RuleMatch.CIDR} - {resource.http && ( - - {RuleMatch.PATH} - - )}
From 5f95500b6f77d3da6dfb27cc189217adc7d0a884 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 19 Feb 2025 21:42:42 -0500 Subject: [PATCH 17/61] Crowdsec installer works? --- install/config.go | 58 +++++++++++++++++++ install/config/docker-compose.yml | 1 + install/crowdsec.go | 95 +++++++++++++------------------ install/main.go | 68 +++++++++++++++++++++- 4 files changed, 165 insertions(+), 57 deletions(-) diff --git a/install/config.go b/install/config.go index f87bb1af..c31149d6 100644 --- a/install/config.go +++ b/install/config.go @@ -333,3 +333,61 @@ func replaceInFile(filepath, oldStr, newStr string) error { return nil } + +func CheckAndAddTraefikLogVolume(composePath string) error { + // Read the docker-compose.yml file + data, err := os.ReadFile(composePath) + if err != nil { + return fmt.Errorf("error reading compose file: %w", err) + } + + // Parse YAML into a generic map + var compose map[string]interface{} + if err := yaml.Unmarshal(data, &compose); err != nil { + return fmt.Errorf("error parsing compose file: %w", err) + } + + // Get services section + services, ok := compose["services"].(map[string]interface{}) + if !ok { + return fmt.Errorf("services section not found or invalid") + } + + // Get traefik service + traefik, ok := services["traefik"].(map[string]interface{}) + if !ok { + return fmt.Errorf("traefik service not found or invalid") + } + + // Check volumes + logVolume := "./config/traefik/logs:/var/log/traefik" + var volumes []interface{} + + if existingVolumes, ok := traefik["volumes"].([]interface{}); ok { + // Check if volume already exists + for _, v := range existingVolumes { + if v.(string) == logVolume { + fmt.Println("Traefik log volume is already configured") + return nil + } + } + volumes = existingVolumes + } + + // Add new volume + volumes = append(volumes, logVolume) + traefik["volumes"] = volumes + + // Write updated config back to file + newData, err := MarshalYAMLWithIndent(compose, 2) + if err != nil { + return fmt.Errorf("error marshaling updated compose file: %w", err) + } + + if err := os.WriteFile(composePath, newData, 0644); err != nil { + return fmt.Errorf("error writing updated compose file: %w", err) + } + + fmt.Println("Added traefik log volume and created logs directory") + return nil +} diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 42604ab4..f6ce7892 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -52,6 +52,7 @@ services: volumes: - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates + - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs networks: default: diff --git a/install/crowdsec.go b/install/crowdsec.go index 6d25a633..d98b2acf 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -6,7 +6,6 @@ import ( "os" "os/exec" "strings" - "time" ) func installCrowdsec(config Config) error { @@ -59,70 +58,30 @@ func installCrowdsec(config Config) error { os.Exit(1) } - if err := retrieveBouncerKey(config); err != nil { - return fmt.Errorf("bouncer key retrieval failed: %v", err) + if err := CheckAndAddTraefikLogVolume("docker-compose.yml"); err != nil { + fmt.Printf("Error checking and adding Traefik log volume: %v\n", err) + os.Exit(1) } - // if err := startContainers(); err != nil { - // return fmt.Errorf("failed to start containers: %v", err) - // } - - return nil -} - -func retrieveBouncerKey(config Config) error { - - fmt.Println("Retrieving bouncer key. Please be patient...") - - // Start crowdsec container - cmd := exec.Command("docker", "compose", "up", "-d", "crowdsec") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to start crowdsec: %v", err) + if err := startContainers(); err != nil { + return fmt.Errorf("failed to start containers: %v", err) } - // verify that the container is running if not keep waiting for 10 more seconds then return an error - count := 0 - for { - cmd := exec.Command("docker", "inspect", "-f", "{{.State.Running}}", "crowdsec") - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to inspect crowdsec container: %v", err) - } - if strings.TrimSpace(string(output)) == "true" { - break - } - time.Sleep(10 * time.Second) - count++ - - if count > 4 { - return fmt.Errorf("crowdsec container is not running") - } - } - - // Get bouncer key - output, err := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer").Output() + // get API key + apiKey, err := GetCrowdSecAPIKey() if err != nil { - return fmt.Errorf("failed to get bouncer key: %v", err) + return fmt.Errorf("failed to get API key: %v", err) + } + config.TraefikBouncerKey = apiKey + + if err := replaceInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK", config.TraefikBouncerKey); err != nil { + return fmt.Errorf("failed to replace bouncer key: %v", err) } - // Parse key from output - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if strings.Contains(line, "key:") { - config.TraefikBouncerKey = strings.TrimSpace(strings.Split(line, ":")[1]) - fmt.Println("Bouncer key:", config.TraefikBouncerKey) - break - } + if err := restartContainer("traefik"); err != nil { + return fmt.Errorf("failed to restart containers: %v", err) } - // Stop crowdsec container - cmd = exec.Command("docker", "compose", "down") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to stop crowdsec: %v", err) - } - - fmt.Println("Bouncer key retrieved successfully.") - return nil } @@ -136,3 +95,27 @@ func checkIsCrowdsecInstalledInCompose() bool { // Check for crowdsec service return bytes.Contains(content, []byte("crowdsec:")) } + +func GetCrowdSecAPIKey() (string, error) { + // First, ensure the container is running + if err := waitForContainer("crowdsec"); err != nil { + return "", fmt.Errorf("waiting for container: %w", err) + } + + // Execute the command to get the API key + cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("executing command: %w", err) + } + + // Trim any whitespace from the output + apiKey := strings.TrimSpace(out.String()) + if apiKey == "" { + return "", fmt.Errorf("empty API key returned") + } + + return apiKey, nil +} diff --git a/install/main.go b/install/main.go index bca19100..9064b4f7 100644 --- a/install/main.go +++ b/install/main.go @@ -4,14 +4,16 @@ import ( "bufio" "embed" "fmt" + "io" "io/fs" "os" - "io" + "time" "os/exec" "path/filepath" "runtime" "strings" "syscall" + "bytes" "text/template" "unicode" @@ -590,6 +592,42 @@ func startContainers() error { return nil } +func restartContainer(container string) error { + fmt.Printf("Restarting %s container...\n", container) + + // Check which docker compose command is available + var useNewStyle bool + checkCmd := exec.Command("docker", "compose", "version") + if err := checkCmd.Run(); err == nil { + useNewStyle = true + } else { + // Check if docker-compose (old style) is available + checkCmd = exec.Command("docker-compose", "version") + if err := checkCmd.Run(); err != nil { + return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) + } + } + + // Helper function to execute docker compose commands + executeCommand := func(args ...string) error { + var cmd *exec.Cmd + if useNewStyle { + cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + } else { + cmd = exec.Command("docker-compose", args...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + if err := executeCommand("-f", "docker-compose.yml", "restart", container); err != nil { + return fmt.Errorf("failed to restart %s container: %v", container, err) + } + + return nil +} + func copyFile(src, dst string) error { source, err := os.Open(src) if err != nil { @@ -613,4 +651,32 @@ func moveFile(src, dst string) error { } return os.Remove(src) +} + +func waitForContainer(containerName string) error { + maxAttempts := 30 + retryInterval := time.Second * 2 + + for attempt := 0; attempt < maxAttempts; attempt++ { + // Check if container is running + cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName) + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + // If the container doesn't exist or there's another error, wait and retry + time.Sleep(retryInterval) + continue + } + + isRunning := strings.TrimSpace(out.String()) == "true" + if isRunning { + return nil + } + + // Container exists but isn't running yet, wait and retry + time.Sleep(retryInterval) + } + + return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds())) } \ No newline at end of file From c877bb1187ba48c4b73ea6cd98f358a84aa93e2a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 19 Feb 2025 23:00:59 -0500 Subject: [PATCH 18/61] bug fixes to smooth out multi domain inputs forms --- server/routers/resource/createResource.ts | 8 +- server/routers/resource/updateResource.ts | 4 +- .../settings/resources/CreateResourceForm.tsx | 136 ++++++++++++------ .../resources/[resourceId]/general/page.tsx | 20 ++- 4 files changed, 111 insertions(+), 57 deletions(-) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 3bc7c05c..2f2bed5a 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -31,7 +31,10 @@ const createResourceParamsSchema = z const createHttpResourceSchema = z .object({ name: z.string().min(1).max(255), - subdomain: z.string().optional(), + subdomain: z + .string() + .optional() + .transform((val) => val?.toLowerCase()), isBaseDomain: z.boolean().optional(), siteId: z.number(), http: z.boolean(), @@ -128,7 +131,7 @@ export async function createResource( ); } - if (!req.body?.http) { + if (typeof req.body.http !== "boolean") { return next( createHttpError(HttpCode.BAD_REQUEST, "http field is required") ); @@ -233,6 +236,7 @@ async function createHttpResource( .values({ siteId, fullDomain, + domainId, orgId, name, subdomain, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 46c36542..ce574299 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -30,7 +30,9 @@ const updateResourceParamsSchema = z const updateHttpResourceBodySchema = z .object({ name: z.string().min(1).max(255).optional(), - subdomain: subdomainSchema.optional(), + subdomain: subdomainSchema + .optional() + .transform((val) => val?.toLowerCase()), ssl: z.boolean().optional(), sso: z.boolean().optional(), blockAccess: z.boolean().optional(), diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 6adf8003..e7f4f763 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -119,6 +119,7 @@ export default function CreateResourceForm({ open, setOpen }: CreateResourceFormProps) { + const [formKey, setFormKey] = useState(0); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); @@ -209,6 +210,7 @@ export default function CreateResourceForm({ setBaseDomains(domains); if (domains.length) { form.setValue("domainId", domains[0].domainId); + setFormKey((k) => k + 1); } } }; @@ -229,7 +231,7 @@ export default function CreateResourceForm({ protocol: data.protocol, proxyPort: data.http ? undefined : data.proxyPort, siteId: data.siteId, - isBaseDomain: data.isBaseDomain + isBaseDomain: data.http ? undefined : data.isBaseDomain } ) .catch((e) => { @@ -281,7 +283,7 @@ export default function CreateResourceForm({ {!showSnippets && ( -
+ Toggle if this is an HTTP resource or a - raw TCP/UDP resource. + raw TCP/UDP + resource. @@ -381,49 +384,88 @@ export default function CreateResourceForm({ {form.watch("http") && ( <> {domainType === "subdomain" ? ( - ( - - {!env.flags - .allowBaseDomainResources && ( - - Subdomain - - )} - {domainType === - "subdomain" && ( - - { - form.setValue( - "subdomain", - value - ); - form.setValue( - "domainId", - selectedDomainId - ); - }} - /> - - )} - - +
+ {!env.flags + .allowBaseDomainResources && ( + + Subdomain + )} - /> +
+
+ ( + + + + )} + /> +
+
+ ( + + + + + )} + /> +
+
+
) : ( diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 6bcc3fde..c8603b0f 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -108,6 +108,7 @@ type GeneralFormValues = z.infer; type TransferFormValues = z.infer; export default function GeneralForm() { + const [formKey, setFormKey] = useState(0); const params = useParams(); const { resource, updateResource } = useResourceContext(); const { org } = useOrgContext(); @@ -178,6 +179,7 @@ export default function GeneralForm() { if (res?.status === 200) { const domains = res.data.data.domains; setBaseDomains(domains); + setFormKey((key) => key + 1); } }; @@ -191,10 +193,10 @@ export default function GeneralForm() { const res = await api .post(`resource/${resource?.resourceId}`, { name: data.name, - subdomain: data.subdomain, + subdomain: data.http ? data.subdomain : undefined, proxyPort: data.proxyPort, - isBaseDomain: data.isBaseDomain, - domainId: data.domainId + isBaseDomain: data.http ? data.isBaseDomain : undefined, + domainId: data.http ? data.domainId : undefined }) .catch((e) => { toast({ @@ -219,6 +221,8 @@ export default function GeneralForm() { proxyPort: data.proxyPort, isBaseDomain: data.isBaseDomain }); + + router.refresh(); } setSaveLoading(false); } @@ -265,7 +269,7 @@ export default function GeneralForm() { - + )} @@ -370,9 +375,10 @@ export default function GeneralForm() { field.onChange } defaultValue={ - field.value || - baseDomains[0] - ?.domainId + field.value + } + value={ + field.value } > From 372932985db67047dba5aef96e440e77a9b54b52 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 23 Feb 2025 17:34:28 -0500 Subject: [PATCH 19/61] update README.md --- README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 61e1a689..324680f5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ +

Tunneled Mesh Reverse Proxy Server with Access Control

+
+ +_Your own self-hosted zero trust tunnel._ + +
+ -

Tunneled Mesh Reverse Proxy Server with Access Control

-
- -_Your own self-hosted zero trust tunnel._ - -
- Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports. Preview @@ -108,7 +108,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected 1. **Deploy the Central Server**: - - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs. + - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs. + +> [!TIP] +> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal! +> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you sign up using [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone. 2. **Domain Configuration**: From f59f0ee57dc66e83be73127b626975a1eab8cf29 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 23 Feb 2025 21:44:02 -0500 Subject: [PATCH 20/61] Merge yaml files instead? --- install/config.go | 184 +++++++++++++++++--------------------------- install/crowdsec.go | 20 ++--- 2 files changed, 82 insertions(+), 122 deletions(-) diff --git a/install/config.go b/install/config.go index c31149d6..3be62601 100644 --- a/install/config.go +++ b/install/config.go @@ -106,118 +106,6 @@ func findPattern(s, pattern string) int { return bytes.Index([]byte(s), []byte(pattern)) } -func copyEntryPoints(sourceFile, destFile string) error { - // Read source file - sourceData, err := os.ReadFile(sourceFile) - if err != nil { - return fmt.Errorf("error reading source file: %w", err) - } - - // Read destination file - destData, err := os.ReadFile(destFile) - if err != nil { - return fmt.Errorf("error reading destination file: %w", err) - } - - // Parse source YAML - var sourceYAML map[string]interface{} - if err := yaml.Unmarshal(sourceData, &sourceYAML); err != nil { - return fmt.Errorf("error parsing source YAML: %w", err) - } - - // Parse destination YAML - var destYAML map[string]interface{} - if err := yaml.Unmarshal(destData, &destYAML); err != nil { - return fmt.Errorf("error parsing destination YAML: %w", err) - } - - // Get entryPoints section from source - entryPoints, ok := sourceYAML["entryPoints"] - if !ok { - return fmt.Errorf("entryPoints section not found in source file") - } - - // Update entryPoints in destination - destYAML["entryPoints"] = entryPoints - - // Marshal updated destination YAML - // updatedData, err := yaml.Marshal(destYAML) - updatedData, err := MarshalYAMLWithIndent(destYAML, 2) - if err != nil { - return fmt.Errorf("error marshaling updated YAML: %w", err) - } - - // Write updated YAML back to destination file - if err := os.WriteFile(destFile, updatedData, 0644); err != nil { - return fmt.Errorf("error writing to destination file: %w", err) - } - - return nil -} - -func copyWebsecureEntryPoint(sourceFile, destFile string) error { - // Read source file - sourceData, err := os.ReadFile(sourceFile) - if err != nil { - return fmt.Errorf("error reading source file: %w", err) - } - - // Read destination file - destData, err := os.ReadFile(destFile) - if err != nil { - return fmt.Errorf("error reading destination file: %w", err) - } - - // Parse source YAML - var sourceYAML map[string]interface{} - if err := yaml.Unmarshal(sourceData, &sourceYAML); err != nil { - return fmt.Errorf("error parsing source YAML: %w", err) - } - - // Parse destination YAML - var destYAML map[string]interface{} - if err := yaml.Unmarshal(destData, &destYAML); err != nil { - return fmt.Errorf("error parsing destination YAML: %w", err) - } - - // Get entryPoints section from source - entryPoints, ok := sourceYAML["entryPoints"].(map[string]interface{}) - if !ok { - return fmt.Errorf("entryPoints section not found in source file or has invalid format") - } - - // Get websecure configuration - websecure, ok := entryPoints["websecure"] - if !ok { - return fmt.Errorf("websecure entrypoint not found in source file") - } - - // Get or create entryPoints section in destination - destEntryPoints, ok := destYAML["entryPoints"].(map[string]interface{}) - if !ok { - // If entryPoints section doesn't exist, create it - destEntryPoints = make(map[string]interface{}) - destYAML["entryPoints"] = destEntryPoints - } - - // Update websecure in destination - destEntryPoints["websecure"] = websecure - - // Marshal updated destination YAML - // updatedData, err := yaml.Marshal(destYAML) - updatedData, err := MarshalYAMLWithIndent(destYAML, 2) - if err != nil { - return fmt.Errorf("error marshaling updated YAML: %w", err) - } - - // Write updated YAML back to destination file - if err := os.WriteFile(destFile, updatedData, 0644); err != nil { - return fmt.Errorf("error writing to destination file: %w", err) - } - - return nil -} - func copyDockerService(sourceFile, destFile, serviceName string) error { // Read source file sourceData, err := os.ReadFile(sourceFile) @@ -391,3 +279,75 @@ func CheckAndAddTraefikLogVolume(composePath string) error { fmt.Println("Added traefik log volume and created logs directory") return nil } + +// MergeYAML merges two YAML files, where the contents of the second file +// are merged into the first file. In case of conflicts, values from the +// second file take precedence. +func MergeYAML(baseFile, overlayFile string) error { + // Read the base YAML file + baseContent, err := os.ReadFile(baseFile) + if err != nil { + return fmt.Errorf("error reading base file: %v", err) + } + + // Read the overlay YAML file + overlayContent, err := os.ReadFile(overlayFile) + if err != nil { + return fmt.Errorf("error reading overlay file: %v", err) + } + + // Parse base YAML into a map + var baseMap map[string]interface{} + if err := yaml.Unmarshal(baseContent, &baseMap); err != nil { + return fmt.Errorf("error parsing base YAML: %v", err) + } + + // Parse overlay YAML into a map + var overlayMap map[string]interface{} + if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil { + return fmt.Errorf("error parsing overlay YAML: %v", err) + } + + // Merge the overlay into the base + merged := mergeMap(baseMap, overlayMap) + + // Marshal the merged result back to YAML + mergedContent, err := MarshalYAMLWithIndent(merged, 2) + if err != nil { + return fmt.Errorf("error marshaling merged YAML: %v", err) + } + + // Write the merged content back to the base file + if err := os.WriteFile(baseFile, mergedContent, 0644); err != nil { + return fmt.Errorf("error writing merged YAML: %v", err) + } + + return nil +} + +// mergeMap recursively merges two maps +func mergeMap(base, overlay map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + // Copy all key-values from base map + for k, v := range base { + result[k] = v + } + + // Merge overlay values + 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 { + result[k] = mergeMap(baseMap, overlayMap) + continue + } + } + } + // Otherwise, overlay value takes precedence + result[k] = v + } + + return result +} diff --git a/install/crowdsec.go b/install/crowdsec.go index d98b2acf..2d56ecc6 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -33,23 +33,23 @@ func installCrowdsec(config Config) error { os.Exit(1) } - if err := copyWebsecureEntryPoint("config/crowdsec/traefik_config.yml", "config/traefik/traefik_config.yml"); err != nil { + if err := MergeYAML("config/traefik/traefik_config.yml", "config/crowdsec/traefik_config.yml"); err != nil { fmt.Printf("Error copying entry points: %v\n", err) os.Exit(1) } + // delete the 2nd file + if err := os.Remove("config/crowdsec/traefik_config.yml"); err != nil { + fmt.Printf("Error removing file: %v\n", err) + os.Exit(1) + } - if err := copyEntryPoints("config/traefik/traefik_config.yml", "config/crowdsec/traefik_config.yml"); err != nil { + if err := MergeYAML("config/traefik/dynamic_config.yml", "config/crowdsec/dynamic_config.yml"); err != nil { fmt.Printf("Error copying entry points: %v\n", err) os.Exit(1) } - - if err := moveFile("config/crowdsec/traefik_config.yml", "config/traefik/traefik_config.yml"); err != nil { - fmt.Printf("Error moving file: %v\n", err) - os.Exit(1) - } - - if err := moveFile("config/crowdsec/dynamic_config.yml", "config/traefik/dynamic_config.yml"); err != nil { - fmt.Printf("Error moving file: %v\n", err) + // delete the 2nd file + if err := os.Remove("config/crowdsec/dynamic_config.yml"); err != nil { + fmt.Printf("Error removing file: %v\n", err) os.Exit(1) } From ff37e07ce6814babef0482f0a3f847dad4c5a415 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 23 Feb 2025 23:03:40 -0500 Subject: [PATCH 21/61] make cookies work with multi-domain --- server/auth/sessions/resource.ts | 8 ++++---- server/lib/config.ts | 10 +++++++--- server/routers/traefik/getTraefikConfig.ts | 13 +++++++++++-- .../resource/[resourceId]/ResourceAuthPortal.tsx | 3 ++- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index 0bc7f092..3336ebde 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -170,9 +170,9 @@ export function serializeResourceSessionCookie( isHttp: boolean = false ): string { if (!isHttp) { - return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`; + return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`; } else { - return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`; + return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`; } } @@ -182,9 +182,9 @@ export function createBlankResourceSessionTokenCookie( isHttp: boolean = false ): string { if (!isHttp) { - return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`; + return `${cookieName}_s=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`; } else { - return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`; + return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${"." + domain}`; } } diff --git a/server/lib/config.ts b/server/lib/config.ts index 04f00335..74f2d9dd 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -41,7 +41,9 @@ const configSchema = z.object({ domains: z.record( z.string(), z.object({ - base_domain: hostnameSchema.transform((url) => url.toLowerCase()) + base_domain: hostnameSchema.transform((url) => url.toLowerCase()), + cert_resolver: z.string(), + prefer_wildcard_cert: z.boolean().optional() }) ), server: z.object({ @@ -89,8 +91,6 @@ const configSchema = z.object({ traefik: z.object({ http_entrypoint: z.string(), https_entrypoint: z.string().optional(), - cert_resolver: z.string().optional(), - prefer_wildcard_cert: z.boolean().optional(), additional_middlewares: z.array(z.string()).optional() }), gerbil: z.object({ @@ -290,6 +290,10 @@ export class Config { ); } + public getDomain(domainId: string) { + return this.rawConfig.domains[domainId]; + } + private createTraefikConfig() { try { // check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 55e0e290..5f6f194f 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -143,9 +143,18 @@ export async function traefikConfigProvider( wildCard = `*.${domainParts.slice(1).join(".")}`; } + const configDomain = config.getDomain(resource.domainId); + + if (!configDomain) { + logger.error( + `Failed to get domain from config for resource ${resource.resourceId}` + ); + continue; + } + const tls = { - certResolver: config.getRawConfig().traefik.cert_resolver, - ...(config.getRawConfig().traefik.prefer_wildcard_cert + certResolver: configDomain.cert_resolver, + ...(configDomain.prefer_wildcard_cert ? { domains: [ { diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 5eda0809..2b959094 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -263,7 +263,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { } if (isAllowed) { - window.location.href = props.redirect; + // window.location.href = props.redirect; + router.refresh(); } } From ccbe56e110460c93a6507c4b177a26f731d94fcc Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 24 Feb 2025 12:03:48 -0500 Subject: [PATCH 22/61] update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 324680f5..5baef277 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta ## Licensing -Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us. +Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io). ## Contributions From e11748fe30e70a054ff57980d0e266e4f0f3348b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 24 Feb 2025 22:06:21 -0500 Subject: [PATCH 23/61] minor bug files, remove unqiue constraint, and start migration --- server/db/schema.ts | 2 +- server/lib/config.ts | 2 +- server/routers/resource/updateResource.ts | 2 + server/setup/scripts/1.0.0-beta15.ts | 78 +++++++++++++++++++ .../resources/[resourceId]/general/page.tsx | 23 ++++-- 5 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 server/setup/scripts/1.0.0-beta15.ts diff --git a/server/db/schema.ts b/server/db/schema.ts index 33d979e7..3d5f234f 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -3,7 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), - baseDomain: text("baseDomain").notNull().unique(), + baseDomain: text("baseDomain").notNull(), configManaged: integer("configManaged", { mode: "boolean" }) .notNull() .default(false) diff --git a/server/lib/config.ts b/server/lib/config.ts index 74f2d9dd..e35c0e8d 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -42,7 +42,7 @@ const configSchema = z.object({ z.string(), z.object({ base_domain: hostnameSchema.transform((url) => url.toLowerCase()), - cert_resolver: z.string(), + cert_resolver: z.string().optional(), prefer_wildcard_cert: z.boolean().optional() }) ), diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index ce574299..2baa61bc 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -68,6 +68,8 @@ const updateHttpResourceBodySchema = z } ); +export type UpdateResourceResponse = Resource; + const updateRawResourceBodySchema = z .object({ name: z.string().min(1).max(255).optional(), diff --git a/server/setup/scripts/1.0.0-beta15.ts b/server/setup/scripts/1.0.0-beta15.ts new file mode 100644 index 00000000..cb116082 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta15.ts @@ -0,0 +1,78 @@ +import db from "@server/db"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import fs from "fs"; +import yaml from "js-yaml"; +import { sql } from "drizzle-orm"; + +const version = "1.0.0-beta.15"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + db.transaction((trx) => {}); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + try { + // Determine which config file exists + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + // Read and parse the YAML file + let rawConfig: any; + const fileContents = fs.readFileSync(filePath, "utf8"); + rawConfig = yaml.load(fileContents); + + const baseDomain = rawConfig.app.base_domain; + const certResolver = rawConfig.traefik.cert_resolver; + const preferWildcardCert = rawConfig.traefik.prefer_wildcard_cert; + + delete rawConfig.traefik.prefer_wildcard_cert; + delete rawConfig.traefik.cert_resolver; + delete rawConfig.app.base_domain; + + rawConfig.domains = { + domain1: { + base_domain: baseDomain + } + }; + + if (certResolver) { + rawConfig.domains.domain1.cert_resolver = certResolver; + } + + if (preferWildcardCert) { + rawConfig.domains.domain1.prefer_wildcard_cert = preferWildcardCert; + } + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + + console.log(`Moved base_domain to new domains section`); + } catch (e) { + console.log( + `Unable to migrate config file and move base_domain to domains. Error: ${e}` + ); + return; + } + + console.log(`${version} migration complete`); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index c8603b0f..0790605c 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -60,6 +60,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { UpdateResourceResponse } from "@server/routers/resource"; const GeneralFormSchema = z .object({ @@ -191,13 +192,16 @@ export default function GeneralForm() { setSaveLoading(true); const res = await api - .post(`resource/${resource?.resourceId}`, { - name: data.name, - subdomain: data.http ? data.subdomain : undefined, - proxyPort: data.proxyPort, - isBaseDomain: data.http ? data.isBaseDomain : undefined, - domainId: data.http ? data.domainId : undefined - }) + .post>( + `resource/${resource?.resourceId}`, + { + name: data.name, + subdomain: data.http ? data.subdomain : undefined, + proxyPort: data.proxyPort, + isBaseDomain: data.http ? data.isBaseDomain : undefined, + domainId: data.http ? data.domainId : undefined + } + ) .catch((e) => { toast({ variant: "destructive", @@ -215,11 +219,14 @@ export default function GeneralForm() { description: "The resource has been updated successfully" }); + const resource = res.data.data; + updateResource({ name: data.name, subdomain: data.subdomain, proxyPort: data.proxyPort, - isBaseDomain: data.isBaseDomain + isBaseDomain: data.isBaseDomain, + fullDomain: resource.fullDomain }); router.refresh(); From d8183bfd0df010356ef886071c901d71385478db Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 24 Feb 2025 22:25:42 -0500 Subject: [PATCH 24/61] add sql migration --- config/config.example.yml | 7 +++++-- server/setup/scripts/1.0.0-beta15.ts | 20 +++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index de094f03..d60ab2ba 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,9 +1,13 @@ app: dashboard_url: "http://localhost:3002" - base_domain: "localhost" log_level: "info" save_logs: false +domains: + domain1: + base_domain: "example.com" + cert_resolver: "letsencrypt" + server: external_port: 3000 internal_port: 3001 @@ -14,7 +18,6 @@ server: resource_session_request_param: "p_session_request" traefik: - cert_resolver: "letsencrypt" http_entrypoint: "web" https_entrypoint: "websecure" diff --git a/server/setup/scripts/1.0.0-beta15.ts b/server/setup/scripts/1.0.0-beta15.ts index cb116082..35f5b425 100644 --- a/server/setup/scripts/1.0.0-beta15.ts +++ b/server/setup/scripts/1.0.0-beta15.ts @@ -10,7 +10,25 @@ export default async function migration() { console.log(`Running setup script ${version}...`); try { - db.transaction((trx) => {}); + db.transaction((trx) => { + trx.run(sql`CREATE TABLE 'domains' ( + 'domainId' text PRIMARY KEY NOT NULL, + 'baseDomain' text NOT NULL, + 'configManaged' integer DEFAULT false NOT NULL +);`); + + trx.run(sql`CREATE TABLE 'orgDomains' ( + 'orgId' text NOT NULL, + 'domainId' text NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade +);`); + + trx.run( + sql`ALTER TABLE 'resources' ADD 'domainId' text REFERENCES domains(domainId);` + ); + trx.run(sql`ALTER TABLE 'orgs' DROP COLUMN 'domain';`); + }); console.log(`Migrated database schema`); } catch (e) { From ae73a2f3f43c7de6a93100d617983c7dc8c92b15 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 24 Feb 2025 22:32:22 -0500 Subject: [PATCH 25/61] show more than 10 rows in rules and targets table closes #221 --- .../settings/resources/[resourceId]/connectivity/page.tsx | 8 +++++++- .../settings/resources/[resourceId]/rules/page.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index dfd2f66c..8dd10944 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -461,7 +461,13 @@ export default function ReverseProxyTargets(props: { getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel() + getFilteredRowModel: getFilteredRowModel(), + state: { + pagination: { + pageIndex: 0, + pageSize: 1000 + } + } }); if (pageLoading) { diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 7fc16b81..4976ac94 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -520,7 +520,13 @@ export default function ResourceRules(props: { getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel() + getFilteredRowModel: getFilteredRowModel(), + state: { + pagination: { + pageIndex: 0, + pageSize: 1000 + } + } }); if (pageLoading) { From ec9d02a7353e0b02077cf206a09ec7cc7f4f14f6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 24 Feb 2025 22:46:55 -0500 Subject: [PATCH 26/61] clear stale data from db on restart --- server/setup/clearStaleData.ts | 79 ++++++++++++++++++++++++++++++++++ server/setup/index.ts | 2 + 2 files changed, 81 insertions(+) create mode 100644 server/setup/clearStaleData.ts diff --git a/server/setup/clearStaleData.ts b/server/setup/clearStaleData.ts new file mode 100644 index 00000000..13789190 --- /dev/null +++ b/server/setup/clearStaleData.ts @@ -0,0 +1,79 @@ +import { db } from "@server/db"; +import { + emailVerificationCodes, + newtSessions, + passwordResetTokens, + resourceAccessToken, + resourceOtp, + resourceSessions, + sessions, + userInvites +} from "@server/db/schema"; +import logger from "@server/logger"; +import { lt } from "drizzle-orm"; + +export async function clearStaleData() { + try { + await db + .delete(sessions) + .where(lt(sessions.expiresAt, new Date().getTime())); + } catch (e) { + logger.error("Error clearing expired sessions:", e); + } + + try { + await db + .delete(newtSessions) + .where(lt(newtSessions.expiresAt, new Date().getTime())); + } catch (e) { + logger.error("Error clearing expired newtSessions:", e); + } + + try { + await db + .delete(emailVerificationCodes) + .where(lt(emailVerificationCodes.expiresAt, new Date().getTime())); + } catch (e) { + logger.error("Error clearing expired emailVerificationCodes:", e); + } + + try { + await db + .delete(passwordResetTokens) + .where(lt(passwordResetTokens.expiresAt, new Date().getTime())); + } catch (e) { + logger.error("Error clearing expired passwordResetTokens:", e); + } + + try { + await db + .delete(userInvites) + .where(lt(userInvites.expiresAt, new Date().getTime())); + } catch (e) { + logger.error("Error clearing expired userInvites:", e); + } + + try { + await db + .delete(resourceAccessToken) + .where(lt(resourceAccessToken.expiresAt, new Date().getTime())); + } catch (e) { + logger.error("Error clearing expired resourceAccessToken:", e); + } + + try { + await db + .delete(resourceSessions) + .where(lt(resourceSessions.expiresAt, new Date().getTime())); + } catch (e) { + logger.error("Error clearing expired resourceSessions:", e); + } + + try { + await db + .delete(resourceOtp) + .where(lt(resourceOtp.expiresAt, new Date().getTime())); + } catch (e) { + logger.error("Error clearing expired resourceOtp:", e); + } +} diff --git a/server/setup/index.ts b/server/setup/index.ts index 51d87283..b93af2aa 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -2,12 +2,14 @@ import { ensureActions } from "./ensureActions"; import { copyInConfig } from "./copyInConfig"; import { setupServerAdmin } from "./setupServerAdmin"; import logger from "@server/logger"; +import { clearStaleData } from "./clearStaleData"; export async function runSetupFunctions() { try { await copyInConfig(); // copy in the config to the db as needed await setupServerAdmin(); await ensureActions(); // make sure all of the actions are in the db and the roles + await clearStaleData(); } catch (error) { logger.error("Error running setup functions:", error); process.exit(1); From e4789c6b0812097d47a6f02faa9b1eac89ff8ec6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 24 Feb 2025 22:52:38 -0500 Subject: [PATCH 27/61] always check rules even if auth is disabled --- server/routers/badger/verifySession.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index fc1c85f5..1af2eb9e 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -142,16 +142,6 @@ export async function verifyResourceSession( return notAllowed(res); } - if ( - !resource.sso && - !pincode && - !password && - !resource.emailWhitelistEnabled - ) { - logger.debug("Resource allowed because no auth"); - return allowed(res); - } - // check the rules if (resource.applyRules) { const action = await checkRules( @@ -171,6 +161,16 @@ export async function verifyResourceSession( // otherwise its undefined and we pass } + if ( + !resource.sso && + !pincode && + !password && + !resource.emailWhitelistEnabled + ) { + logger.debug("Resource allowed because no auth"); + return allowed(res); + } + const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent( resource.resourceId )}?redirect=${encodeURIComponent(originalRequestURL)}`; From de70c62ea8a1e4a5205154658064bd259b771db5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 25 Feb 2025 22:58:52 -0500 Subject: [PATCH 28/61] adjustments to migration after testing --- server/lib/config.ts | 60 ++++++++++++++++---- server/lib/consts.ts | 2 +- server/setup/copyInConfig.ts | 50 ++++++++-------- server/setup/migrations.ts | 4 +- server/setup/scripts/1.0.0-beta15.ts | 85 +++++++++++++++++++--------- 5 files changed, 139 insertions(+), 62 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index e35c0e8d..7d8d9c8b 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -38,14 +38,45 @@ const configSchema = z.object({ save_logs: z.boolean(), log_failed_attempts: z.boolean().optional() }), - domains: z.record( - z.string(), - z.object({ - base_domain: hostnameSchema.transform((url) => url.toLowerCase()), - cert_resolver: z.string().optional(), - prefer_wildcard_cert: z.boolean().optional() - }) - ), + domains: z + .record( + z.string(), + z.object({ + base_domain: hostnameSchema.transform((url) => + url.toLowerCase() + ), + cert_resolver: z.string().optional(), + prefer_wildcard_cert: z.boolean().optional() + }) + ) + .refine( + (domains) => { + const keys = Object.keys(domains); + + if (keys.length === 0) { + return false; + } + + return true; + }, + { + message: "At least one domain must be defined" + } + ) + .refine( + (domains) => { + const envBaseDomain = process.env.APP_BASE_DOMAIN; + + if (envBaseDomain) { + return hostnameSchema.safeParse(envBaseDomain).success; + } + + return true; + }, + { + message: "APP_BASE_DOMAIN must be a valid hostname" + } + ), server: z.object({ external_port: portSchema .optional() @@ -169,8 +200,6 @@ export class Config { } } - public loadEnvironment() {} - public loadConfig() { const loadConfig = (configPath: string) => { try { @@ -277,6 +306,17 @@ export class Config { : "false"; process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; + if (process.env.APP_BASE_DOMAIN) { + console.log( + `DEPRECATED! APP_BASE_DOMAIN is deprecated and will be removed in a future release. Use the domains section in the configuration file instead. See https://docs.fossorial.io/Pangolin/Configuration/config for more information.` + ); + + parsedConfig.data.domains.domain1 = { + base_domain: process.env.APP_BASE_DOMAIN, + cert_resolver: "letsencrypt" + }; + } + this.rawConfig = parsedConfig.data; } diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 20376f8e..3855ce72 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.0.0-beta.13"; +export const APP_VERSION = "1.0.0-beta.15"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index d1860677..ae77152c 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -51,6 +51,32 @@ export async function copyInConfig() { } } + const allOrgs = await trx.select().from(orgs); + + const existingOrgDomains = await trx.select().from(orgDomains); + const existingOrgDomainSet = new Set( + existingOrgDomains.map((od) => `${od.orgId}-${od.domainId}`) + ); + + const newOrgDomains = []; + for (const org of allOrgs) { + for (const domain of configDomains) { + const key = `${org.orgId}-${domain.domainId}`; + if (!existingOrgDomainSet.has(key)) { + newOrgDomains.push({ + orgId: org.orgId, + domainId: domain.domainId + }); + } + } + } + + if (newOrgDomains.length > 0) { + await trx.insert(orgDomains).values(newOrgDomains).execute(); + } + }); + + await db.transaction(async (trx) => { const allResources = await trx .select() .from(resources) @@ -77,30 +103,6 @@ export async function copyInConfig() { .set({ fullDomain }) .where(eq(resources.resourceId, resource.resourceId)); } - - const allOrgs = await trx.select().from(orgs); - - const existingOrgDomains = await trx.select().from(orgDomains); - const existingOrgDomainSet = new Set( - existingOrgDomains.map((od) => `${od.orgId}-${od.domainId}`) - ); - - const newOrgDomains = []; - for (const org of allOrgs) { - for (const domain of configDomains) { - const key = `${org.orgId}-${domain.domainId}`; - if (!existingOrgDomainSet.has(key)) { - newOrgDomains.push({ - orgId: org.orgId, - domainId: domain.domainId - }); - } - } - } - - if (newOrgDomains.length > 0) { - await trx.insert(orgDomains).values(newOrgDomains).execute(); - } }); // TODO: eventually each exit node could have a different endpoint diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 52a82ad4..99451797 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -15,6 +15,7 @@ import m6 from "./scripts/1.0.0-beta9"; import m7 from "./scripts/1.0.0-beta10"; import m8 from "./scripts/1.0.0-beta12"; import m13 from "./scripts/1.0.0-beta13"; +import m15 from "./scripts/1.0.0-beta15"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -29,7 +30,8 @@ const migrations = [ { version: "1.0.0-beta.9", run: m6 }, { version: "1.0.0-beta.10", run: m7 }, { version: "1.0.0-beta.12", run: m8 }, - { version: "1.0.0-beta.13", run: m13 } + { version: "1.0.0-beta.13", run: m13 }, + { version: "1.0.0-beta.15", run: m15 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.0.0-beta15.ts b/server/setup/scripts/1.0.0-beta15.ts index 35f5b425..aa2044a4 100644 --- a/server/setup/scripts/1.0.0-beta15.ts +++ b/server/setup/scripts/1.0.0-beta15.ts @@ -3,38 +3,14 @@ import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import fs from "fs"; import yaml from "js-yaml"; import { sql } from "drizzle-orm"; +import { domains, orgDomains, resources } from "@server/db/schema"; const version = "1.0.0-beta.15"; export default async function migration() { console.log(`Running setup script ${version}...`); - try { - db.transaction((trx) => { - trx.run(sql`CREATE TABLE 'domains' ( - 'domainId' text PRIMARY KEY NOT NULL, - 'baseDomain' text NOT NULL, - 'configManaged' integer DEFAULT false NOT NULL -);`); - - trx.run(sql`CREATE TABLE 'orgDomains' ( - 'orgId' text NOT NULL, - 'domainId' text NOT NULL, - FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, - FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade -);`); - - trx.run( - sql`ALTER TABLE 'resources' ADD 'domainId' text REFERENCES domains(domainId);` - ); - trx.run(sql`ALTER TABLE 'orgs' DROP COLUMN 'domain';`); - }); - - console.log(`Migrated database schema`); - } catch (e) { - console.log("Unable to migrate database schema"); - throw e; - } + let domain = ""; try { // Determine which config file exists @@ -84,11 +60,68 @@ export default async function migration() { const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); + domain = baseDomain; + console.log(`Moved base_domain to new domains section`); } catch (e) { console.log( `Unable to migrate config file and move base_domain to domains. Error: ${e}` ); + throw e; + } + + try { + db.transaction((trx) => { + trx.run(sql`CREATE TABLE 'domains' ( + 'domainId' text PRIMARY KEY NOT NULL, + 'baseDomain' text NOT NULL, + 'configManaged' integer DEFAULT false NOT NULL +);`); + + trx.run(sql`CREATE TABLE 'orgDomains' ( + 'orgId' text NOT NULL, + 'domainId' text NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade +);`); + + trx.run( + sql`ALTER TABLE 'resources' ADD 'domainId' text REFERENCES domains(domainId);` + ); + trx.run(sql`ALTER TABLE 'orgs' DROP COLUMN 'domain';`); + }); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + try { + await db.transaction(async (trx) => { + await trx + .insert(domains) + .values({ + domainId: "domain1", + baseDomain: domain, + configManaged: true + }) + .execute(); + await trx.update(resources).set({ domainId: "domain1" }); + const existingOrgDomains = await trx.select().from(orgDomains); + for (const orgDomain of existingOrgDomains) { + await trx + .insert(orgDomains) + .values({ orgId: orgDomain.orgId, domainId: "domain1" }) + .execute(); + } + }); + + console.log(`Updated resources table with new domainId`); + } catch (e) { + console.log( + `Unable to update resources table with new domainId. Error: ${e}` + ); return; } From 332804ed71965d8851fc0f85af5610c3e8719af9 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 25 Feb 2025 23:41:43 -0500 Subject: [PATCH 29/61] Add base_domain new config --- install/fs/config.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/install/fs/config.yml b/install/fs/config.yml index 8e4411e7..ff99b1f9 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -1,9 +1,13 @@ app: dashboard_url: "https://{{.DashboardDomain}}" - base_domain: "{{.BaseDomain}}" log_level: "info" save_logs: false +domains: + domain1: + base_domain: "{{.BaseDomain}}" + cert_resolver: "letsencrypt" + server: external_port: 3000 internal_port: 3001 From 06c434a5eaefe81f9f17f8b57e80ef52ca95d761 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 26 Feb 2025 21:13:52 -0500 Subject: [PATCH 30/61] Copy in the right versions when building --- install/Makefile | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/install/Makefile b/install/Makefile index e8e9cd2e..9bde02cf 100644 --- a/install/Makefile +++ b/install/Makefile @@ -1,13 +1,24 @@ -all: build +all: update-versions go-build-release put-back -build: - CGO_ENABLED=0 go build -o bin/installer - -release: +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 clean: - rm -f bin/installer rm -f bin/installer_linux_amd64 rm -f bin/installer_linux_arm64 + +update-versions: + @echo "Fetching latest versions..." + cp main.go main.go.bak && \ + PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \ + 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" + +put-back: + mv main.go.bak main.go \ No newline at end of file From 20f1a6372b5a0099926fb7147b26616ee3cc1265 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 26 Feb 2025 21:24:35 -0500 Subject: [PATCH 31/61] small visual improvements --- package.json | 2 +- .../settings/access/roles/CreateRoleForm.tsx | 2 - .../settings/access/users/InviteUserForm.tsx | 1 - src/app/[orgId]/settings/general/page.tsx | 5 +- .../settings/resources/CreateResourceForm.tsx | 66 +- .../[resourceId]/ResourceInfoBox.tsx | 2 +- .../SetResourcePasswordForm.tsx | 3 +- .../authentication/SetResourcePincodeForm.tsx | 2 +- .../[resourceId]/authentication/page.tsx | 28 +- .../[resourceId]/connectivity/page.tsx | 3 +- .../resources/[resourceId]/general/page.tsx | 33 +- .../share-links/CreateShareLinkForm.tsx | 4 +- .../[orgId]/settings/sites/CreateSiteForm.tsx | 22 +- .../settings/sites/[niceId]/general/page.tsx | 6 +- .../auth/reset-password/ResetPasswordForm.tsx | 21 +- .../[resourceId]/ResourceAuthPortal.tsx | 3 - src/app/auth/signup/SignupForm.tsx | 4 +- src/app/auth/verify-email/VerifyEmailForm.tsx | 3 +- src/app/setup/page.tsx | 2 - src/components/Disable2FaForm.tsx | 1 - src/components/Enable2FaForm.tsx | 2 - src/components/LoginForm.tsx | 2 - src/components/tags/autocomplete.tsx | 353 +++++++ src/components/tags/tag-input.tsx | 949 ++++++++++++++++++ src/components/tags/tag-list.tsx | 205 ++++ src/components/tags/tag-popover.tsx | 207 ++++ src/components/tags/tag.tsx | 169 ++++ src/components/ui/button.tsx | 4 +- src/components/ui/input.tsx | 4 +- src/components/ui/radio-group.tsx | 2 +- src/components/ui/select.tsx | 2 +- 31 files changed, 1976 insertions(+), 136 deletions(-) create mode 100644 src/components/tags/autocomplete.tsx create mode 100644 src/components/tags/tag-input.tsx create mode 100644 src/components/tags/tag-list.tsx create mode 100644 src/components/tags/tag-popover.tsx create mode 100644 src/components/tags/tag.tsx diff --git a/package.json b/package.json index 74f75f79..4bcb2d40 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "cookie-parser": "1.4.7", "cors": "2.8.5", "drizzle-orm": "0.38.3", - "emblor": "1.4.7", "eslint": "9.17.0", "eslint-config-next": "15.1.3", "express": "4.21.2", @@ -71,6 +70,7 @@ "qrcode.react": "4.2.0", "react": "19.0.0", "react-dom": "19.0.0", + "react-easy-sort": "^1.6.0", "react-hook-form": "7.54.2", "rebuild": "0.1.2", "semver": "7.6.3", diff --git a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx index cd9aecc5..d95f3e20 100644 --- a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx @@ -136,7 +136,6 @@ export default function CreateRoleForm({ Role Name @@ -152,7 +151,6 @@ export default function CreateRoleForm({ Description diff --git a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx index c812717d..c629c0bc 100644 --- a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx +++ b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx @@ -195,7 +195,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { Email diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index c2ac225c..959a32a6 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -210,11 +210,11 @@ export default function GeneralPage() { + This is the display name of the - org + organization. - )} /> @@ -238,7 +238,6 @@ export default function GeneralPage() { - Danger Zone diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index e7f4f763..ea8542f6 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -289,28 +289,6 @@ export default function CreateResourceForm({ className="space-y-4" id="create-resource-form" > - ( - - Name - - - - - This is the name that will - be displayed for this - resource. - - - - )} - /> - {!env.flags.allowRawResources || ( )} + ( + + Name + + + + + + This is display name for the + resource. + + + )} + /> + {form.watch("http") && env.flags.allowBaseDomainResources && (
@@ -392,7 +388,7 @@ export default function CreateResourceForm({ )}
-
+
)} />
-
+
+ The protocol to use - for the resource + for the resource. - )} /> @@ -579,7 +575,6 @@ export default function CreateResourceForm({ + The port number to proxy requests to (required for - non-HTTP resources) + non-HTTP resources). - )} /> @@ -644,7 +639,7 @@ export default function CreateResourceForm({ - + No site @@ -687,11 +682,12 @@ export default function CreateResourceForm({ - - This is the site that will - be used in the dashboard. - + + This site will provide + connectivity to the + resource. + )} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index ab135db7..89b6d050 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -42,7 +42,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { This resource is protected with - at least one auth method. + at least one authentication method.
) : ( diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx index fa329ba9..35eb29a3 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx @@ -136,17 +136,16 @@ export default function SetResourcePasswordForm({ + Users will be able to access this resource by entering this password. It must be at least 4 characters long. - )} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx index 704d3f44..4a850b33 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx @@ -167,13 +167,13 @@ export default function SetResourcePincodeForm({
+ Users will be able to access this resource by entering this PIN code. It must be at least 6 digits long. - )} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 0e3dc7bc..07bd0e53 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -15,7 +15,7 @@ import { } from "@server/routers/resource"; import { Button } from "@app/components/ui/button"; import { set, z } from "zod"; -import { Tag } from "emblor"; +// import { Tag } from "emblor"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { @@ -27,7 +27,7 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { TagInput } from "emblor"; +// import { TagInput } from "emblor"; // import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { ListUsersResponse } from "@server/routers/user"; import { Switch } from "@app/components/ui/switch"; @@ -49,6 +49,7 @@ import { } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -429,7 +430,6 @@ export default function ResourceAuthenticationPage() { Roles - {/* @ts-ignore */} + - These roles will be able - to access this resource. Admins can always access this resource. - )} /> @@ -494,7 +492,6 @@ export default function ResourceAuthenticationPage() { Users - {/* @ts-ignore */} - - Users added here will be - able to access this - resource. A user will - always have access to a - resource if they have a - role that has access to - it. - )} @@ -732,7 +720,9 @@ export default function ResourceAuthenticationPage() { /> - Press enter to add an email after typing it in the input field. + Press enter to add an + email after typing it in + the input field. )} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 8dd10944..d8d1e8a0 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -483,8 +483,7 @@ export default function ReverseProxyTargets(props: { SSL Configuration - Setup SSL to secure your connections with - LetsEncrypt certificates + Setup SSL to secure your connections with Let's Encrypt certificates diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 0790605c..0eef41d6 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -291,11 +291,11 @@ export default function GeneralForm() { + This is the display name of the resource. - )} /> @@ -348,7 +348,7 @@ export default function GeneralForm() { )}
-
+
( - - - + + + + + + )} />
-
+
+ This is the port that will be used to access the resource. - )} /> @@ -583,7 +585,7 @@ export default function GeneralForm() { @@ -626,10 +628,6 @@ export default function GeneralForm() { - - Select the new site to transfer - this resource to. - )} @@ -645,7 +643,6 @@ export default function GeneralForm() { loading={transferLoading} disabled={transferLoading} form="transfer-form" - variant="destructive" > Transfer Resource diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index aa9cb74c..bd5778ef 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -305,7 +305,7 @@ export default function CreateShareLinkForm({ - + No @@ -374,7 +374,6 @@ export default function CreateShareLinkForm({ @@ -437,7 +436,6 @@ export default function CreateShareLinkForm({ diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx index 98fcc2a6..0a4cca14 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx @@ -272,17 +272,13 @@ PersistentKeepalive = 5` Name - + - - This is the name that will be displayed for - this site. - + + This is the the display name for the + site. + )} /> @@ -319,10 +315,10 @@ PersistentKeepalive = 5` + This is how you will expose connections. - )} /> @@ -354,7 +350,7 @@ PersistentKeepalive = 5` ) : form.watch("method") === "wireguard" && isLoading ? (

Loading WireGuard configuration...

- ) : form.watch("method") === "newt" ? ( + ) : form.watch("method") === "newt" && siteDefaults ? ( <>

- Expand for Docker Deployment - Details + Expand for Docker + Deployment Details

diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index b1c24405..66a7ddd1 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -33,7 +33,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useState } from "react"; const GeneralFormSchema = z.object({ - name: z.string() + name: z.string().nonempty("Name is required") }); type GeneralFormValues = z.infer; @@ -114,11 +114,11 @@ export default function GeneralPage() { + This is the display name of the - site + site. - )} /> diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index a87762fe..5bc525bf 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -38,7 +38,7 @@ import { Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert"; import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; @@ -223,16 +223,13 @@ export default function ResetPasswordForm({ Email - + + We'll send a password reset code to this email address. - )} /> @@ -255,7 +252,6 @@ export default function ResetPasswordForm({ Email @@ -276,12 +272,15 @@ export default function ResetPasswordForm({ + + Check your email for the + reset code. + )} /> @@ -298,7 +297,6 @@ export default function ResetPasswordForm({ @@ -317,7 +315,6 @@ export default function ResetPasswordForm({ @@ -349,7 +346,9 @@ export default function ResetPasswordForm({ @@ -518,7 +517,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { @@ -577,7 +575,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index f839284e..9a4129b4 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -145,7 +145,7 @@ export default function SignupForm({ Email - + @@ -160,7 +160,6 @@ export default function SignupForm({ @@ -177,7 +176,6 @@ export default function SignupForm({ diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx index e0dcbffb..67ee0b02 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -145,7 +145,6 @@ export default function VerifyEmailForm({ Email @@ -196,12 +195,12 @@ export default function VerifyEmailForm({
+ We sent a verification code to your email address. Please enter the code to verify your email address. - )} /> diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 318ceed9..7966d587 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -200,7 +200,6 @@ export default function StepperForm() { { @@ -242,7 +241,6 @@ export default function StepperForm() { diff --git a/src/components/Disable2FaForm.tsx b/src/components/Disable2FaForm.tsx index 3e87bce4..28da2b31 100644 --- a/src/components/Disable2FaForm.tsx +++ b/src/components/Disable2FaForm.tsx @@ -135,7 +135,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) { diff --git a/src/components/Enable2FaForm.tsx b/src/components/Enable2FaForm.tsx index d9167999..7d9764d7 100644 --- a/src/components/Enable2FaForm.tsx +++ b/src/components/Enable2FaForm.tsx @@ -200,7 +200,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { @@ -246,7 +245,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 2190e2f5..3be11528 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -147,7 +147,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { Email @@ -166,7 +165,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { diff --git a/src/components/tags/autocomplete.tsx b/src/components/tags/autocomplete.tsx new file mode 100644 index 00000000..95c57bec --- /dev/null +++ b/src/components/tags/autocomplete.tsx @@ -0,0 +1,353 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +// import { Command, CommandList, CommandItem, CommandGroup, CommandEmpty } from '../ui/command'; +import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { Button } from "../ui/button"; +import { cn } from "@app/lib/cn"; + +type AutocompleteProps = { + tags: TagType[]; + setTags: React.Dispatch>; + setInputValue: React.Dispatch>; + setTagCount: React.Dispatch>; + autocompleteOptions: TagType[]; + maxTags?: number; + onTagAdd?: (tag: string) => void; + onTagRemove?: (tag: string) => void; + allowDuplicates: boolean; + children: React.ReactNode; + inlineTags?: boolean; + classStyleProps: TagInputStyleClassesProps["autoComplete"]; + usePortal?: boolean; +}; + +export const Autocomplete: React.FC = ({ + tags, + setTags, + setInputValue, + setTagCount, + autocompleteOptions, + maxTags, + onTagAdd, + onTagRemove, + allowDuplicates, + inlineTags, + children, + classStyleProps, + usePortal +}) => { + const triggerContainerRef = useRef(null); + const triggerRef = useRef(null); + const inputRef = useRef(null); + const popoverContentRef = useRef(null); + + const [popoverWidth, setPopoverWidth] = useState(0); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [inputFocused, setInputFocused] = useState(false); + const [popooverContentTop, setPopoverContentTop] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(-1); + + // Dynamically calculate the top position for the popover content + useEffect(() => { + if (!triggerContainerRef.current || !triggerRef.current) return; + setPopoverContentTop( + triggerContainerRef.current?.getBoundingClientRect().bottom - + triggerRef.current?.getBoundingClientRect().bottom + ); + }, [tags]); + + // Close the popover when clicking outside of it + useEffect(() => { + const handleOutsideClick = ( + event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent + ) => { + if ( + isPopoverOpen && + triggerContainerRef.current && + popoverContentRef.current && + !triggerContainerRef.current.contains(event.target as Node) && + !popoverContentRef.current.contains(event.target as Node) + ) { + setIsPopoverOpen(false); + } + }; + + document.addEventListener("mousedown", handleOutsideClick); + + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + }; + }, [isPopoverOpen]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open && triggerContainerRef.current) { + const { width } = + triggerContainerRef.current.getBoundingClientRect(); + setPopoverWidth(width); + } + + if (open) { + inputRef.current?.focus(); + setIsPopoverOpen(open); + } + }, + [inputFocused] + ); + + const handleInputFocus = ( + event: + | React.FocusEvent + | React.FocusEvent + ) => { + if (triggerContainerRef.current) { + const { width } = + triggerContainerRef.current.getBoundingClientRect(); + setPopoverWidth(width); + setIsPopoverOpen(true); + } + + // Only set inputFocused to true if the popover is already open. + // This will prevent the popover from opening due to an input focus if it was initially closed. + if (isPopoverOpen) { + setInputFocused(true); + } + + const userOnFocus = (children as React.ReactElement).props.onFocus; + if (userOnFocus) userOnFocus(event); + }; + + const handleInputBlur = ( + event: + | React.FocusEvent + | React.FocusEvent + ) => { + setInputFocused(false); + + // Allow the popover to close if no other interactions keep it open + if (!isPopoverOpen) { + setIsPopoverOpen(false); + } + + const userOnBlur = (children as React.ReactElement).props.onBlur; + if (userOnBlur) userOnBlur(event); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!isPopoverOpen) return; + + switch (event.key) { + case "ArrowUp": + event.preventDefault(); + setSelectedIndex((prevIndex) => + prevIndex <= 0 + ? autocompleteOptions.length - 1 + : prevIndex - 1 + ); + break; + case "ArrowDown": + event.preventDefault(); + setSelectedIndex((prevIndex) => + prevIndex === autocompleteOptions.length - 1 + ? 0 + : prevIndex + 1 + ); + break; + case "Enter": + event.preventDefault(); + if (selectedIndex !== -1) { + toggleTag(autocompleteOptions[selectedIndex]); + setSelectedIndex(-1); + } + break; + } + }; + + const toggleTag = (option: TagType) => { + // Check if the tag already exists in the array + const index = tags.findIndex((tag) => tag.text === option.text); + + if (index >= 0) { + // Tag exists, remove it + const newTags = tags.filter((_, i) => i !== index); + setTags(newTags); + setTagCount((prevCount) => prevCount - 1); + if (onTagRemove) { + onTagRemove(option.text); + } + } else { + // Tag doesn't exist, add it if allowed + if ( + !allowDuplicates && + tags.some((tag) => tag.text === option.text) + ) { + // If duplicates aren't allowed and a tag with the same text exists, do nothing + return; + } + + // Add the tag if it doesn't exceed max tags, if applicable + if (!maxTags || tags.length < maxTags) { + setTags([...tags, option]); + setTagCount((prevCount) => prevCount + 1); + setInputValue(""); + if (onTagAdd) { + onTagAdd(option.text); + } + } + } + setSelectedIndex(-1); + }; + + const childrenWithProps = React.cloneElement( + children as React.ReactElement, + { + onKeyDown: handleKeyDown, + onFocus: handleInputFocus, + onBlur: handleInputBlur, + ref: inputRef + } + ); + + return ( +
+ +
+ {childrenWithProps} + + + +
+ +
+ {autocompleteOptions.length > 0 ? ( +
+ + Suggestions + +
+ {autocompleteOptions.map((option, index) => { + const isSelected = index === selectedIndex; + return ( +
toggleTag(option)} + > +
+ {option.text} + {tags.some( + (tag) => + tag.text === option.text + ) && ( + + + + )} +
+
+ ); + })} +
+ ) : ( +
+ No results found. +
+ )} +
+ + +
+ ); +}; diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx new file mode 100644 index 00000000..ef1a27fb --- /dev/null +++ b/src/components/tags/tag-input.tsx @@ -0,0 +1,949 @@ +"use client"; + +import React, { useMemo } from "react"; +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; +import { type VariantProps } from "class-variance-authority"; +// import { CommandInput } from '../ui/command'; +import { TagPopover } from "./tag-popover"; +import { TagList } from "./tag-list"; +import { tagVariants } from "./tag"; +import { Autocomplete } from "./autocomplete"; +import { cn } from "@app/lib/cn"; + +export enum Delimiter { + Comma = ",", + Enter = "Enter" +} + +type OmittedInputProps = Omit< + React.InputHTMLAttributes, + "size" | "value" +>; + +export type Tag = { + id: string; + text: string; +}; + +export interface TagInputStyleClassesProps { + inlineTagsContainer?: string; + tagPopover?: { + popoverTrigger?: string; + popoverContent?: string; + }; + tagList?: { + container?: string; + sortableList?: string; + }; + autoComplete?: { + command?: string; + popoverTrigger?: string; + popoverContent?: string; + commandList?: string; + commandGroup?: string; + commandItem?: string; + }; + tag?: { + body?: string; + closeButton?: string; + }; + input?: string; + clearAllButton?: string; +} + +export interface TagInputProps + extends OmittedInputProps, + VariantProps { + placeholder?: string; + tags: Tag[]; + setTags: React.Dispatch>; + enableAutocomplete?: boolean; + autocompleteOptions?: Tag[]; + maxTags?: number; + minTags?: number; + readOnly?: boolean; + disabled?: boolean; + onTagAdd?: (tag: string) => void; + onTagRemove?: (tag: string) => void; + allowDuplicates?: boolean; + validateTag?: (tag: string) => boolean; + delimiter?: Delimiter; + showCount?: boolean; + placeholderWhenFull?: string; + sortTags?: boolean; + delimiterList?: string[]; + truncate?: number; + minLength?: number; + maxLength?: number; + usePopoverForTags?: boolean; + value?: + | string + | number + | readonly string[] + | { id: string; text: string }[]; + autocompleteFilter?: (option: string) => boolean; + direction?: "row" | "column"; + onInputChange?: (value: string) => void; + customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; + onTagClick?: (tag: Tag) => void; + draggable?: boolean; + inputFieldPosition?: "bottom" | "top"; + clearAll?: boolean; + onClearAll?: () => void; + inputProps?: React.InputHTMLAttributes; + restrictTagsToAutocompleteOptions?: boolean; + inlineTags?: boolean; + activeTagIndex: number | null; + setActiveTagIndex: React.Dispatch>; + styleClasses?: TagInputStyleClassesProps; + usePortal?: boolean; + addOnPaste?: boolean; + addTagsOnBlur?: boolean; + generateTagId?: () => string; +} + +const TagInput = React.forwardRef( + (props, ref) => { + const { + id, + placeholder, + tags, + setTags, + variant, + size, + shape, + enableAutocomplete, + autocompleteOptions, + maxTags, + delimiter = Delimiter.Comma, + onTagAdd, + onTagRemove, + allowDuplicates, + showCount, + validateTag, + placeholderWhenFull = "Max tags reached", + sortTags, + delimiterList, + truncate, + autocompleteFilter, + borderStyle, + textCase, + interaction, + animation, + textStyle, + minLength, + maxLength, + direction = "row", + onInputChange, + customTagRenderer, + onFocus, + onBlur, + onTagClick, + draggable = false, + inputFieldPosition = "bottom", + clearAll = false, + onClearAll, + usePopoverForTags = false, + inputProps = {}, + restrictTagsToAutocompleteOptions, + inlineTags = true, + addTagsOnBlur = false, + activeTagIndex, + setActiveTagIndex, + styleClasses = {}, + disabled = false, + usePortal = false, + addOnPaste = false, + generateTagId = uuid + } = props; + + const [inputValue, setInputValue] = React.useState(""); + const [tagCount, setTagCount] = React.useState( + Math.max(0, tags.length) + ); + const inputRef = React.useRef(null); + + if ( + (maxTags !== undefined && maxTags < 0) || + (props.minTags !== undefined && props.minTags < 0) + ) { + console.warn("maxTags and minTags cannot be less than 0"); + // error + return null; + } + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (addOnPaste && newValue.includes(delimiter)) { + const splitValues = newValue + .split(delimiter) + .map((v) => v.trim()) + .filter((v) => v); + splitValues.forEach((value) => { + if (!value) return; // Skip empty strings from split + + const newTagText = value.trim(); + + // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true + if ( + restrictTagsToAutocompleteOptions && + !autocompleteOptions?.some( + (option) => option.text === newTagText + ) + ) { + console.warn( + "Tag not allowed as per autocomplete options" + ); + return; + } + + if (validateTag && !validateTag(newTagText)) { + console.warn("Invalid tag as per validateTag"); + return; + } + + if (minLength && newTagText.length < minLength) { + console.warn(`Tag "${newTagText}" is too short`); + return; + } + + if (maxLength && newTagText.length > maxLength) { + console.warn(`Tag "${newTagText}" is too long`); + return; + } + + const newTagId = generateTagId(); + + // Add tag if duplicates are allowed or tag does not already exist + if ( + allowDuplicates || + !tags.some((tag) => tag.text === newTagText) + ) { + if (maxTags === undefined || tags.length < maxTags) { + // Check for maxTags limit + const newTag = { id: newTagId, text: newTagText }; + setTags((prevTags) => [...prevTags, newTag]); + onTagAdd?.(newTagText); + } else { + console.warn( + "Reached the maximum number of tags allowed" + ); + } + } else { + console.warn(`Duplicate tag "${newTagText}" not added`); + } + }); + setInputValue(""); + } else { + setInputValue(newValue); + } + onInputChange?.(newValue); + }; + + const handleInputFocus = ( + event: React.FocusEvent + ) => { + setActiveTagIndex(null); // Reset active tag index when the input field gains focus + onFocus?.(event); + }; + + const handleInputBlur = (event: React.FocusEvent) => { + if (addTagsOnBlur && inputValue.trim()) { + const newTagText = inputValue.trim(); + + if (validateTag && !validateTag(newTagText)) { + return; + } + + if (minLength && newTagText.length < minLength) { + console.warn("Tag is too short"); + return; + } + + if (maxLength && newTagText.length > maxLength) { + console.warn("Tag is too long"); + return; + } + + if ( + (allowDuplicates || + !tags.some((tag) => tag.text === newTagText)) && + (maxTags === undefined || tags.length < maxTags) + ) { + const newTagId = generateTagId(); + setTags([...tags, { id: newTagId, text: newTagText }]); + onTagAdd?.(newTagText); + setTagCount((prevTagCount) => prevTagCount + 1); + setInputValue(""); + } + } + + onBlur?.(event); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + delimiterList + ? delimiterList.includes(e.key) + : e.key === delimiter || e.key === Delimiter.Enter + ) { + e.preventDefault(); + const newTagText = inputValue.trim(); + + // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true + if ( + restrictTagsToAutocompleteOptions && + !autocompleteOptions?.some( + (option) => option.text === newTagText + ) + ) { + // error + return; + } + + if (validateTag && !validateTag(newTagText)) { + return; + } + + if (minLength && newTagText.length < minLength) { + console.warn("Tag is too short"); + // error + return; + } + + // Validate maxLength + if (maxLength && newTagText.length > maxLength) { + // error + console.warn("Tag is too long"); + return; + } + + const newTagId = generateTagId(); + + if ( + newTagText && + (allowDuplicates || + !tags.some((tag) => tag.text === newTagText)) && + (maxTags === undefined || tags.length < maxTags) + ) { + setTags([...tags, { id: newTagId, text: newTagText }]); + onTagAdd?.(newTagText); + setTagCount((prevTagCount) => prevTagCount + 1); + } + setInputValue(""); + } else { + switch (e.key) { + case "Delete": + if (activeTagIndex !== null) { + e.preventDefault(); + const newTags = [...tags]; + newTags.splice(activeTagIndex, 1); + setTags(newTags); + setActiveTagIndex((prev) => + newTags.length === 0 + ? null + : prev! >= newTags.length + ? newTags.length - 1 + : prev + ); + setTagCount((prevTagCount) => prevTagCount - 1); + onTagRemove?.(tags[activeTagIndex].text); + } + break; + case "Backspace": + if (activeTagIndex !== null) { + e.preventDefault(); + const newTags = [...tags]; + newTags.splice(activeTagIndex, 1); + setTags(newTags); + setActiveTagIndex((prev) => + prev! === 0 ? null : prev! - 1 + ); + setTagCount((prevTagCount) => prevTagCount - 1); + onTagRemove?.(tags[activeTagIndex].text); + } + break; + case "ArrowRight": + e.preventDefault(); + if (activeTagIndex === null) { + setActiveTagIndex(0); + } else { + setActiveTagIndex((prev) => + prev! + 1 >= tags.length ? 0 : prev! + 1 + ); + } + break; + case "ArrowLeft": + e.preventDefault(); + if (activeTagIndex === null) { + setActiveTagIndex(tags.length - 1); + } else { + setActiveTagIndex((prev) => + prev! === 0 ? tags.length - 1 : prev! - 1 + ); + } + break; + case "Home": + e.preventDefault(); + setActiveTagIndex(0); + break; + case "End": + e.preventDefault(); + setActiveTagIndex(tags.length - 1); + break; + } + } + }; + + const removeTag = (idToRemove: string) => { + setTags(tags.filter((tag) => tag.id !== idToRemove)); + onTagRemove?.( + tags.find((tag) => tag.id === idToRemove)?.text || "" + ); + setTagCount((prevTagCount) => prevTagCount - 1); + }; + + const onSortEnd = (oldIndex: number, newIndex: number) => { + setTags((currentTags) => { + const newTags = [...currentTags]; + const [removedTag] = newTags.splice(oldIndex, 1); + newTags.splice(newIndex, 0, removedTag); + + return newTags; + }); + }; + + const handleClearAll = () => { + if (!onClearAll) { + setActiveTagIndex(-1); + setTags([]); + return; + } + onClearAll?.(); + }; + + // const filteredAutocompleteOptions = autocompleteFilter + // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) + // : autocompleteOptions; + const filteredAutocompleteOptions = useMemo(() => { + return (autocompleteOptions || []).filter((option) => + option.text + .toLowerCase() + .includes(inputValue ? inputValue.toLowerCase() : "") + ); + }, [inputValue, autocompleteOptions]); + + const displayedTags = sortTags ? [...tags].sort() : tags; + + const truncatedTags = truncate + ? tags.map((tag) => ({ + id: tag.id, + text: + tag.text?.length > truncate + ? `${tag.text.substring(0, truncate)}...` + : tag.text + })) + : displayedTags; + + return ( +
0 ? "gap-3" : ""} ${ + inputFieldPosition === "bottom" + ? "flex-col" + : inputFieldPosition === "top" + ? "flex-col-reverse" + : "flex-row" + }`} + > + {!usePopoverForTags && + (!inlineTags ? ( + + ) : ( + !enableAutocomplete && ( +
+
+ + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + // className, + styleClasses?.input + )} + autoComplete={ + enableAutocomplete ? "on" : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> +
+
+ ) + ))} + {enableAutocomplete ? ( +
+ + {!usePopoverForTags ? ( + !inlineTags ? ( + // = maxTags ? placeholderWhenFull : placeholder} + // ref={inputRef} + // value={inputValue} + // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} + // onChangeCapture={handleInputChange} + // onKeyDown={handleKeyDown} + // onFocus={handleInputFocus} + // onBlur={handleInputBlur} + // className={cn( + // 'w-full', + // // className, + // styleClasses?.input, + // )} + // /> + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + // className, + styleClasses?.input + )} + autoComplete={ + enableAutocomplete ? "on" : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> + ) : ( +
+ + {/* = maxTags ? placeholderWhenFull : placeholder} + ref={inputRef} + value={inputValue} + disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} + onChangeCapture={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + inlineTags={inlineTags} + className={cn( + 'border-0 flex-1 w-fit h-5', + // className, + styleClasses?.input, + )} + /> */} + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + // className, + styleClasses?.input + )} + autoComplete={ + enableAutocomplete + ? "on" + : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> +
+ ) + ) : ( + + {/* = maxTags ? placeholderWhenFull : placeholder} + ref={inputRef} + value={inputValue} + disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} + onChangeCapture={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + className={cn( + 'w-full', + // className, + styleClasses?.input, + )} + /> */} + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + // className, + styleClasses?.input + )} + autoComplete={ + enableAutocomplete ? "on" : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> + + )} +
+
+ ) : ( +
+ {!usePopoverForTags ? ( + !inlineTags ? ( + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + className={cn( + styleClasses?.input + // className + )} + autoComplete={ + enableAutocomplete ? "on" : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + /> + ) : null + ) : ( + + = maxTags + ? placeholderWhenFull + : placeholder + } + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + {...inputProps} + autoComplete={ + enableAutocomplete ? "on" : "off" + } + list={ + enableAutocomplete + ? "autocomplete-options" + : undefined + } + disabled={ + disabled || + (maxTags !== undefined && + tags.length >= maxTags) + } + className={cn( + "border-0 w-full", + styleClasses?.input + // className + )} + /> + + )} +
+ )} + + {showCount && maxTags && ( +
+ + {`${tagCount}`}/{`${maxTags}`} + +
+ )} + {clearAll && ( + + )} +
+ ); + } +); + +TagInput.displayName = "TagInput"; + +export function uuid() { + return crypto.getRandomValues(new Uint32Array(1))[0].toString(); +} + +export { TagInput }; diff --git a/src/components/tags/tag-list.tsx b/src/components/tags/tag-list.tsx new file mode 100644 index 00000000..a9e30ffe --- /dev/null +++ b/src/components/tags/tag-list.tsx @@ -0,0 +1,205 @@ +import React from "react"; +import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; +import { Tag, TagProps } from "./tag"; +import SortableList, { SortableItem } from "react-easy-sort"; +import { cn } from "@app/lib/cn"; + +export type TagListProps = { + tags: TagType[]; + customTagRenderer?: (tag: TagType, isActiveTag: boolean) => React.ReactNode; + direction?: TagProps["direction"]; + onSortEnd: (oldIndex: number, newIndex: number) => void; + className?: string; + inlineTags?: boolean; + activeTagIndex?: number | null; + setActiveTagIndex?: (index: number | null) => void; + classStyleProps: { + tagListClasses: TagInputStyleClassesProps["tagList"]; + tagClasses: TagInputStyleClassesProps["tag"]; + }; + disabled?: boolean; +} & Omit; + +const DropTarget: React.FC = () => { + return
; +}; + +export const TagList: React.FC = ({ + tags, + customTagRenderer, + direction, + draggable, + onSortEnd, + className, + inlineTags, + activeTagIndex, + setActiveTagIndex, + classStyleProps, + disabled, + ...tagListProps +}) => { + const [draggedTagId, setDraggedTagId] = React.useState(null); + + const handleMouseDown = (id: string) => { + setDraggedTagId(id); + }; + + const handleMouseUp = () => { + setDraggedTagId(null); + }; + + return ( + <> + {!inlineTags ? ( +
+ {draggable ? ( + } + > + {tags.map((tagObj, index) => ( + +
+ handleMouseDown(tagObj.id) + } + onMouseLeave={handleMouseUp} + className={cn( + { + "border border-solid border-primary rounded-md": + draggedTagId === tagObj.id + }, + "transition-all duration-200 ease-in-out" + )} + > + {customTagRenderer ? ( + customTagRenderer( + tagObj, + index === activeTagIndex + ) + ) : ( + + )} +
+
+ ))} +
+ ) : ( + tags.map((tagObj, index) => + customTagRenderer ? ( + customTagRenderer( + tagObj, + index === activeTagIndex + ) + ) : ( + + ) + ) + )} +
+ ) : ( + <> + {draggable ? ( + } + > + {tags.map((tagObj, index) => ( + +
+ handleMouseDown(tagObj.id) + } + onMouseLeave={handleMouseUp} + className={cn( + { + "border border-solid border-primary rounded-md": + draggedTagId === tagObj.id + }, + "transition-all duration-200 ease-in-out" + )} + > + {customTagRenderer ? ( + customTagRenderer( + tagObj, + index === activeTagIndex + ) + ) : ( + + )} +
+
+ ))} +
+ ) : ( + tags.map((tagObj, index) => + customTagRenderer ? ( + customTagRenderer( + tagObj, + index === activeTagIndex + ) + ) : ( + + ) + ) + )} + + )} + + ); +}; diff --git a/src/components/tags/tag-popover.tsx b/src/components/tags/tag-popover.tsx new file mode 100644 index 00000000..6145b498 --- /dev/null +++ b/src/components/tags/tag-popover.tsx @@ -0,0 +1,207 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; +import { TagList, TagListProps } from "./tag-list"; +import { Button } from "../ui/button"; +import { cn } from "@app/lib/cn"; + +type TagPopoverProps = { + children: React.ReactNode; + tags: TagType[]; + customTagRenderer?: (tag: TagType, isActiveTag: boolean) => React.ReactNode; + activeTagIndex?: number | null; + setActiveTagIndex?: (index: number | null) => void; + classStyleProps: { + popoverClasses: TagInputStyleClassesProps["tagPopover"]; + tagListClasses: TagInputStyleClassesProps["tagList"]; + tagClasses: TagInputStyleClassesProps["tag"]; + }; + disabled?: boolean; + usePortal?: boolean; +} & TagListProps; + +export const TagPopover: React.FC = ({ + children, + tags, + customTagRenderer, + activeTagIndex, + setActiveTagIndex, + classStyleProps, + disabled, + usePortal, + ...tagProps +}) => { + const triggerContainerRef = useRef(null); + const triggerRef = useRef(null); + const popoverContentRef = useRef(null); + const inputRef = useRef(null); + + const [popoverWidth, setPopoverWidth] = useState(0); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [inputFocused, setInputFocused] = useState(false); + const [sideOffset, setSideOffset] = useState(0); + + useEffect(() => { + const handleResize = () => { + if (triggerContainerRef.current && triggerRef.current) { + setPopoverWidth(triggerContainerRef.current.offsetWidth); + setSideOffset( + triggerContainerRef.current.offsetWidth - + triggerRef?.current?.offsetWidth + ); + } + }; + + handleResize(); // Call on mount and layout changes + + window.addEventListener("resize", handleResize); // Adjust on window resize + return () => window.removeEventListener("resize", handleResize); + }, [triggerContainerRef, triggerRef]); + + // Close the popover when clicking outside of it + useEffect(() => { + const handleOutsideClick = ( + event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent + ) => { + if ( + isPopoverOpen && + triggerContainerRef.current && + popoverContentRef.current && + !triggerContainerRef.current.contains(event.target as Node) && + !popoverContentRef.current.contains(event.target as Node) + ) { + setIsPopoverOpen(false); + } + }; + + document.addEventListener("mousedown", handleOutsideClick); + + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + }; + }, [isPopoverOpen]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open && triggerContainerRef.current) { + setPopoverWidth(triggerContainerRef.current.offsetWidth); + } + + if (open) { + inputRef.current?.focus(); + setIsPopoverOpen(open); + } + }, + [inputFocused] + ); + + const handleInputFocus = ( + event: + | React.FocusEvent + | React.FocusEvent + ) => { + // Only set inputFocused to true if the popover is already open. + // This will prevent the popover from opening due to an input focus if it was initially closed. + if (isPopoverOpen) { + setInputFocused(true); + } + + const userOnFocus = (children as React.ReactElement).props.onFocus; + if (userOnFocus) userOnFocus(event); + }; + + const handleInputBlur = ( + event: + | React.FocusEvent + | React.FocusEvent + ) => { + setInputFocused(false); + + // Allow the popover to close if no other interactions keep it open + if (!isPopoverOpen) { + setIsPopoverOpen(false); + } + + const userOnBlur = (children as React.ReactElement).props.onBlur; + if (userOnBlur) userOnBlur(event); + }; + + return ( + +
+ {React.cloneElement(children as React.ReactElement, { + onFocus: handleInputFocus, + onBlur: handleInputBlur, + ref: inputRef + })} + + + +
+ +
+

+ Entered Tags +

+

+ These are the tags you've entered. +

+
+ +
+
+ ); +}; diff --git a/src/components/tags/tag.tsx b/src/components/tags/tag.tsx new file mode 100644 index 00000000..e3e4f838 --- /dev/null +++ b/src/components/tags/tag.tsx @@ -0,0 +1,169 @@ +import React from "react"; +import { Button } from "../ui/button"; +import { + TagInputProps, + TagInputStyleClassesProps, + type Tag as TagType +} from "./tag-input"; + +import { cva } from "class-variance-authority"; +import { cn } from "@app/lib/cn"; + +export const tagVariants = cva( + "transition-all border inline-flex items-center text-sm pl-2 rounded-md", + { + variants: { + variant: { + default: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50", + primary: + "bg-primary border-primary text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50", + destructive: + "bg-destructive border-destructive text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50" + }, + size: { + sm: "text-xs h-7", + md: "text-sm h-8", + lg: "text-base h-9", + xl: "text-lg h-10" + }, + shape: { + default: "rounded-sm", + rounded: "rounded-lg", + square: "rounded-none", + pill: "rounded-full" + }, + borderStyle: { + default: "border-solid", + none: "border-none", + dashed: "border-dashed", + dotted: "border-dotted", + double: "border-double" + }, + textCase: { + uppercase: "uppercase", + lowercase: "lowercase", + capitalize: "capitalize" + }, + interaction: { + clickable: "cursor-pointer hover:shadow-md", + nonClickable: "cursor-default" + }, + animation: { + none: "", + fadeIn: "animate-fadeIn", + slideIn: "animate-slideIn", + bounce: "animate-bounce" + }, + textStyle: { + normal: "font-normal", + bold: "font-bold", + italic: "italic", + underline: "underline", + lineThrough: "line-through" + } + }, + defaultVariants: { + variant: "default", + size: "md", + shape: "default", + borderStyle: "default", + interaction: "nonClickable", + animation: "fadeIn", + textStyle: "normal" + } + } +); + +export type TagProps = { + tagObj: TagType; + variant: TagInputProps["variant"]; + size: TagInputProps["size"]; + shape: TagInputProps["shape"]; + borderStyle: TagInputProps["borderStyle"]; + textCase: TagInputProps["textCase"]; + interaction: TagInputProps["interaction"]; + animation: TagInputProps["animation"]; + textStyle: TagInputProps["textStyle"]; + onRemoveTag: (id: string) => void; + isActiveTag?: boolean; + tagClasses?: TagInputStyleClassesProps["tag"]; + disabled?: boolean; +} & Pick; + +export const Tag: React.FC = ({ + tagObj, + direction, + draggable, + onTagClick, + onRemoveTag, + variant, + size, + shape, + borderStyle, + textCase, + interaction, + animation, + textStyle, + isActiveTag, + tagClasses, + disabled +}) => { + return ( + onTagClick?.(tagObj)} + > + {tagObj.text} + + + ); +}; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 3aa288a9..0afd01e1 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -15,9 +15,9 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: - "border border-input bg-card hover:bg-accent hover:text-accent-foreground", + "border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground", secondary: - "bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80", + "bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", text: "", link: "text-primary underline-offset-4 hover:underline", diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 9a085cb2..e10e2589 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -15,7 +15,7 @@ const Input = React.forwardRef( ( span]:line-clamp-1", + "flex h-9 w-full items-center justify-between border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className, "rounded-md" )} From 0bd0cc76fb98fc21ec76161da1b40bf19c2152ea Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 27 Feb 2025 10:57:37 -0500 Subject: [PATCH 32/61] Fix cicd with go-build-release installer --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index be0fc303..bc581582 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -64,7 +64,7 @@ jobs: - name: Build installer working-directory: install run: | - make release + make go-build-release - name: Upload artifacts from /install/bin uses: actions/upload-artifact@v4 From f8add1f0983f468afd7d1e2ca2573f5be8b4be22 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 27 Feb 2025 10:57:37 -0500 Subject: [PATCH 33/61] Fix cicd with go-build-release installer --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index be0fc303..bc581582 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -64,7 +64,7 @@ jobs: - name: Build installer working-directory: install run: | - make release + make go-build-release - name: Upload artifacts from /install/bin uses: actions/upload-artifact@v4 From 57a37a01ce9bc085a1491cfaa9de370c03aa7d16 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 27 Feb 2025 11:01:53 -0500 Subject: [PATCH 34/61] full width settings for user --- src/app/[orgId]/settings/access/users/[userId]/layout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index a5dfbcb8..11ae20ac 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -73,7 +73,6 @@ export default async function UserLayoutProps(props: UserLayoutProps) { {children} From 0e38f58a7f8f443640398310f0310781fd24a3f5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 1 Mar 2025 17:45:38 -0500 Subject: [PATCH 35/61] minor visual enhancements --- server/lib/config.ts | 3 +- .../settings/access/roles/CreateRoleForm.tsx | 34 +- .../settings/access/roles/DeleteRoleForm.tsx | 38 +- .../settings/access/users/InviteUserForm.tsx | 12 +- .../settings/access/users/UsersTable.tsx | 2 +- .../settings/access/users/[userId]/layout.tsx | 2 +- src/app/[orgId]/settings/layout.tsx | 41 +- .../settings/resources/CreateResourceForm.tsx | 931 +++++++++--------- .../settings/resources/ResourcesTable.tsx | 2 +- .../SetResourcePasswordForm.tsx | 26 +- .../authentication/SetResourcePincodeForm.tsx | 30 +- .../[resourceId]/authentication/page.tsx | 34 +- .../[resourceId]/connectivity/page.tsx | 45 +- .../resources/[resourceId]/general/page.tsx | 759 +++++++------- .../resources/[resourceId]/rules/page.tsx | 18 +- .../share-links/CreateShareLinkForm.tsx | 156 +-- .../settings/share-links/ShareLinksTable.tsx | 14 + .../[orgId]/settings/sites/CreateSiteForm.tsx | 57 +- .../settings/sites/CreateSiteModal.tsx | 6 +- src/app/[orgId]/settings/sites/SitesTable.tsx | 2 +- .../settings/sites/[niceId]/layout.tsx | 2 +- src/app/globals.css | 4 +- src/app/layout.tsx | 6 +- src/components/ConfirmDeleteDialog.tsx | 24 +- src/components/Credenza.tsx | 2 +- src/components/Disable2FaForm.tsx | 41 +- src/components/Enable2FaForm.tsx | 13 +- src/components/PlaceHolderLoader.tsx | 21 + src/components/Settings.tsx | 2 +- src/components/SettingsSectionTitle.tsx | 2 +- src/components/tags/tag-input.tsx | 4 +- src/components/tags/tag.tsx | 6 +- src/components/ui/button.tsx | 2 + src/components/ui/dialog.tsx | 2 +- src/components/ui/input-otp.tsx | 2 +- src/components/ui/table.tsx | 2 +- src/components/ui/toast.tsx | 2 +- 37 files changed, 1195 insertions(+), 1154 deletions(-) create mode 100644 src/components/PlaceHolderLoader.tsx diff --git a/server/lib/config.ts b/server/lib/config.ts index 7d8d9c8b..61ca3d66 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -184,7 +184,8 @@ const configSchema = z.object({ disable_signup_without_invite: z.boolean().optional(), disable_user_create_org: z.boolean().optional(), allow_raw_resources: z.boolean().optional(), - allow_base_domain_resources: z.boolean().optional() + allow_base_domain_resources: z.boolean().optional(), + allow_local_sites: z.boolean().optional() }) .optional() }); diff --git a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx index d95f3e20..2312d67a 100644 --- a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx @@ -7,7 +7,7 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { toast } from "@app/hooks/useToast"; @@ -24,11 +24,11 @@ import { CredenzaDescription, CredenzaFooter, CredenzaHeader, - CredenzaTitle, + CredenzaTitle } from "@app/components/Credenza"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -40,13 +40,13 @@ type CreateRoleFormProps = { const formSchema = z.object({ name: z.string({ message: "Name is required" }).max(32), - description: z.string().max(255).optional(), + description: z.string().max(255).optional() }); export default function CreateRoleForm({ open, setOpen, - afterCreate, + afterCreate }: CreateRoleFormProps) { const { org } = useOrgContext(); @@ -58,8 +58,8 @@ export default function CreateRoleForm({ resolver: zodResolver(formSchema), defaultValues: { name: "", - description: "", - }, + description: "" + } }); async function onSubmit(values: z.infer) { @@ -70,7 +70,7 @@ export default function CreateRoleForm({ `/org/${org?.org.orgId}/role`, { name: values.name, - description: values.description, + description: values.description } as CreateRoleBody ) .catch((e) => { @@ -80,7 +80,7 @@ export default function CreateRoleForm({ description: formatAxiosError( e, "An error occurred while creating the role." - ), + ) }); }); @@ -88,7 +88,7 @@ export default function CreateRoleForm({ toast({ variant: "default", title: "Role created", - description: "The role has been successfully created.", + description: "The role has been successfully created." }); if (open) { @@ -135,9 +135,7 @@ export default function CreateRoleForm({ Role Name - + @@ -150,9 +148,7 @@ export default function CreateRoleForm({ Description - + @@ -162,6 +158,9 @@ export default function CreateRoleForm({ + + + - - - diff --git a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx index 6bd41df8..80d97267 100644 --- a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx @@ -7,7 +7,7 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -23,7 +23,7 @@ import { CredenzaDescription, CredenzaFooter, CredenzaHeader, - CredenzaTitle, + CredenzaTitle } from "@app/components/Credenza"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { ListRolesResponse } from "@server/routers/role"; @@ -32,10 +32,10 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue, + SelectValue } from "@app/components/ui/select"; import { RoleRow } from "./RolesTable"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -47,14 +47,14 @@ type CreateRoleFormProps = { }; const formSchema = z.object({ - newRoleId: z.string({ message: "New role is required" }), + newRoleId: z.string({ message: "New role is required" }) }); export default function DeleteRoleForm({ open, roleToDelete, setOpen, - afterDelete, + afterDelete }: CreateRoleFormProps) { const { org } = useOrgContext(); @@ -66,9 +66,9 @@ export default function DeleteRoleForm({ useEffect(() => { async function fetchRoles() { const res = await api - .get>( - `/org/${org?.org.orgId}/roles` - ) + .get< + AxiosResponse + >(`/org/${org?.org.orgId}/roles`) .catch((e) => { console.error(e); toast({ @@ -77,7 +77,7 @@ export default function DeleteRoleForm({ description: formatAxiosError( e, "An error occurred while fetching the roles" - ), + ) }); }); @@ -96,8 +96,8 @@ export default function DeleteRoleForm({ const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { - newRoleId: "", - }, + newRoleId: "" + } }); async function onSubmit(values: z.infer) { @@ -106,8 +106,8 @@ export default function DeleteRoleForm({ const res = await api .delete(`/role/${roleToDelete.roleId}`, { data: { - roleId: values.newRoleId, - }, + roleId: values.newRoleId + } }) .catch((e) => { toast({ @@ -116,7 +116,7 @@ export default function DeleteRoleForm({ description: formatAxiosError( e, "An error occurred while removing the role." - ), + ) }); }); @@ -124,7 +124,7 @@ export default function DeleteRoleForm({ toast({ variant: "default", title: "Role removed", - description: "The role has been successfully removed.", + description: "The role has been successfully removed." }); if (open) { @@ -214,6 +214,9 @@ export default function DeleteRoleForm({
+ + + - - - diff --git a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx index c629c0bc..0285123a 100644 --- a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx +++ b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx @@ -37,7 +37,7 @@ import { } from "@app/components/Credenza"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { ListRolesResponse } from "@server/routers/role"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Checkbox } from "@app/components/ui/checkbox"; @@ -194,9 +194,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { Email - + @@ -340,6 +338,9 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
+ + + - - - diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index 7c11c06b..29529d66 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -185,7 +185,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { - diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 11ae20ac..135c47a3 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -64,7 +64,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
-
+

User {user?.email}

diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index b0b561a2..b9912106 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -1,6 +1,13 @@ import { Metadata } from "next"; import { TopbarNav } from "@app/components/TopbarNav"; -import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react"; +import { + Cog, + Combine, + LinkIcon, + Settings, + Users, + Waypoints +} from "lucide-react"; import { Header } from "@app/components/Header"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; @@ -11,6 +18,14 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { cache } from "react"; import { GetOrgUserResponse } from "@server/routers/user"; import UserProvider from "@app/providers/UserProvider"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import Link from "next/link"; export const dynamic = "force-dynamic"; @@ -38,7 +53,7 @@ const topNavItems = [ { title: "Shareable Links", href: "/{orgId}/settings/share-links", - icon: + icon: }, { title: "General", @@ -95,19 +110,23 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { return ( <> -
-
-
- -
- +
+
+
+
+ +
+ +
+
-
-
- {children} +
+
+ {children} +
); diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index ea8542f6..cbf08d07 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -66,6 +66,7 @@ import CopyTextBox from "@app/components/CopyTextBox"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; import { ListDomainsResponse } from "@server/routers/domain"; +import LoaderPlaceholder from "@app/components/PlaceHolderLoader"; const createResourceFormSchema = z .object({ @@ -140,6 +141,7 @@ export default function CreateResourceForm({ const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( "subdomain" ); + const [loadingPage, setLoadingPage] = useState(true); const form = useForm({ resolver: zodResolver(createResourceFormSchema), @@ -215,8 +217,16 @@ export default function CreateResourceForm({ } }; - fetchSites(); - fetchDomains(); + const load = async () => { + setLoadingPage(true); + + await fetchSites(); + await fetchDomains(); + + setLoadingPage(false); + }; + + load(); }, [open]); async function onSubmit(data: CreateResourceFormValues) { @@ -282,236 +292,482 @@ export default function CreateResourceForm({ - {!showSnippets && ( -
- - {!env.flags.allowRawResources || ( - ( - -
- - HTTP Resource - - - Toggle if this is an - HTTP resource or a - raw TCP/UDP - resource. - -
- - - -
+ {loadingPage ? ( + + ) : ( +
+ {!showSnippets && ( + + - )} - - ( - - Name - - - - - - This is display name for the - resource. - - - )} - /> - - {form.watch("http") && - env.flags.allowBaseDomainResources && ( -
- { - setDomainType( - val as any - ); - form.setValue( - "isBaseDomain", - val === "basedomain" - ); - }} - > -
- - -
-
- - -
-
-
- )} - - {form.watch("http") && ( - <> - {domainType === "subdomain" ? ( -
- {!env.flags - .allowBaseDomainResources && ( - - Subdomain - - )} -
-
- ( - - - - )} - /> -
-
- ( - - - - - )} - /> -
-
-
- ) : ( + className="space-y-4" + id="create-resource-form" + > + {!env.flags.allowRawResources || ( ( - - - + +
+ + HTTP + Resource + + + Toggle if + this is an + HTTP + resource or + a raw + TCP/UDP + resource. + +
+ + +
)} /> )} - - )} - {!form.watch("http") && ( + {!form.watch("http") && ( + + + Learn how to configure + TCP/UDP resources + + + + )} + + ( + + + Name + + + + + + + )} + /> + + {form.watch("http") && + env.flags + .allowBaseDomainResources && ( + ( + + + Domain Type + + + + + )} + /> + )} + + {form.watch("http") && ( + <> + {domainType === + "subdomain" ? ( +
+ + Subdomain + +
+
+ ( + + + + + + + )} + /> +
+
+ ( + + + + + )} + /> +
+
+
+ ) : ( + ( + + + Base + Domain + + + + + )} + /> + )} + + )} + + {!form.watch("http") && ( + <> + ( + + + Protocol + + + + + )} + /> + ( + + + Port Number + + + + field.onChange( + e + .target + .value + ? parseInt( + e + .target + .value + ) + : null + ) + } + /> + + + + The external + port number + to proxy + requests. + + + )} + /> + + )} + + ( + + + Site + + + + + + + + + + + + + No + site + found. + + + {sites.map( + ( + site + ) => ( + { + form.setValue( + "siteId", + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + + This site will + provide connectivity + to the resource. + + + )} + /> + + + )} + + {showSnippets && ( +
+
+
+

+ Traefik: Add Entrypoints +

+ +
+
+ +
+
+

+ Gerbil: Expose Ports in + Docker Compose +

+ +
+
+ - Learn how to configure TCP/UDP - resources + Make sure to follow the full + guide - )} - - {!form.watch("http") && ( - <> - ( - - - Protocol - - - - - The protocol to use - for the resource. - - - )} - /> - ( - - - Port Number - - - - field.onChange( - e.target - .value - ? parseInt( - e - .target - .value - ) - : null - ) - } - /> - - - - The port number to - proxy requests to - (required for - non-HTTP resources). - - - )} - /> - - )} - - ( - - Site - - - - - - - - - - - - No site - found. - - - {sites.map( - ( - site - ) => ( - { - form.setValue( - "siteId", - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - - This site will provide - connectivity to the - resource. - - - )} - /> - - - )} - - {showSnippets && ( -
-
-
- 1
-
-

- Traefik: Add Entrypoints -

- -
-
- -
-
- 2 -
-
-

- Gerbil: Expose Ports in Docker - Compose -

- -
-
- - - - Make sure to follow the full guide - - - + )}
)} + + + {!showSnippets && ( - diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index 848838b3..6ff9e730 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -233,7 +233,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { - diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx index 35eb29a3..f3ab705c 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx @@ -8,7 +8,7 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { toast } from "@app/hooks/useToast"; @@ -24,22 +24,22 @@ import { CredenzaDescription, CredenzaFooter, CredenzaHeader, - CredenzaTitle, + CredenzaTitle } from "@app/components/Credenza"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { Resource } from "@server/db/schema"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; const setPasswordFormSchema = z.object({ - password: z.string().min(4).max(100), + password: z.string().min(4).max(100) }); type SetPasswordFormValues = z.infer; const defaultValues: Partial = { - password: "", + password: "" }; type SetPasswordFormProps = { @@ -53,7 +53,7 @@ export default function SetResourcePasswordForm({ open, setOpen, resourceId, - onSetPassword, + onSetPassword }: SetPasswordFormProps) { const api = createApiClient(useEnvContext()); @@ -61,7 +61,7 @@ export default function SetResourcePasswordForm({ const form = useForm({ resolver: zodResolver(setPasswordFormSchema), - defaultValues, + defaultValues }); useEffect(() => { @@ -76,7 +76,7 @@ export default function SetResourcePasswordForm({ setLoading(true); api.post>(`/resource/${resourceId}/password`, { - password: data.password, + password: data.password }) .catch((e) => { toast({ @@ -85,14 +85,14 @@ export default function SetResourcePasswordForm({ description: formatAxiosError( e, "An error occurred while setting the resource password" - ), + ) }); }) .then(() => { toast({ title: "Resource password set", description: - "The resource password has been set successfully", + "The resource password has been set successfully" }); if (onSetPassword) { @@ -153,6 +153,9 @@ export default function SetResourcePasswordForm({ + + + - - - diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx index 4a850b33..9d89d3bd 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx @@ -8,7 +8,7 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { toast } from "@app/hooks/useToast"; @@ -24,27 +24,27 @@ import { CredenzaDescription, CredenzaFooter, CredenzaHeader, - CredenzaTitle, + CredenzaTitle } from "@app/components/Credenza"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { Resource } from "@server/db/schema"; import { InputOTP, InputOTPGroup, - InputOTPSlot, + InputOTPSlot } from "@app/components/ui/input-otp"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; const setPincodeFormSchema = z.object({ - pincode: z.string().length(6), + pincode: z.string().length(6) }); type SetPincodeFormValues = z.infer; const defaultValues: Partial = { - pincode: "", + pincode: "" }; type SetPincodeFormProps = { @@ -58,7 +58,7 @@ export default function SetResourcePincodeForm({ open, setOpen, resourceId, - onSetPincode, + onSetPincode }: SetPincodeFormProps) { const [loading, setLoading] = useState(false); @@ -66,7 +66,7 @@ export default function SetResourcePincodeForm({ const form = useForm({ resolver: zodResolver(setPincodeFormSchema), - defaultValues, + defaultValues }); useEffect(() => { @@ -81,7 +81,7 @@ export default function SetResourcePincodeForm({ setLoading(true); api.post>(`/resource/${resourceId}/pincode`, { - pincode: data.pincode, + pincode: data.pincode }) .catch((e) => { toast({ @@ -89,15 +89,15 @@ export default function SetResourcePincodeForm({ title: "Error setting resource PIN code", description: formatAxiosError( e, - "An error occurred while setting the resource PIN code", - ), + "An error occurred while setting the resource PIN code" + ) }); }) .then(() => { toast({ title: "Resource PIN code set", description: - "The resource pincode has been set successfully", + "The resource pincode has been set successfully" }); if (onSetPincode) { @@ -181,6 +181,9 @@ export default function SetResourcePincodeForm({ + + + - - - diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index fa0491f0..c50afc4d 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -38,7 +38,8 @@ import { SettingsSectionHeader, SettingsSectionDescription, SettingsSectionBody, - SettingsSectionFooter + SettingsSectionFooter, + SettingsSectionForm } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { InfoPopup } from "@app/components/ui/info-popup"; @@ -438,6 +439,7 @@ export default function ResourceAuthenticationPage() { setActiveRolesTagIndex } placeholder="Select a role" + size="sm" tags={ usersRolesForm.getValues() .roles @@ -466,14 +468,6 @@ export default function ResourceAuthenticationPage() { true } sortTags={true} - styleClasses={{ - tag: { - body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" - }, - input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", - inlineTagsContainer: - "bg-transparent p-2" - }} /> @@ -504,6 +498,7 @@ export default function ResourceAuthenticationPage() { usersRolesForm.getValues() .users } + size="sm" setTags={( newUsers ) => { @@ -528,14 +523,6 @@ export default function ResourceAuthenticationPage() { true } sortTags={true} - styleClasses={{ - tag: { - body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" - }, - input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", - inlineTagsContainer: - "bg-transparent p-2" - }} /> @@ -582,7 +569,7 @@ export default function ResourceAuthenticationPage() {
- diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 0eef41d6..15edd9c7 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -129,6 +129,7 @@ export default function GeneralForm() { ListDomainsResponse["domains"] >([]); + const [loadingPage, setLoadingPage] = useState(true); const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( resource.isBaseDomain ? "basedomain" : "subdomain" ); @@ -184,8 +185,14 @@ export default function GeneralForm() { } }; - fetchDomains(); - fetchSites(); + const load = async () => { + await fetchDomains(); + await fetchSites(); + + setLoadingPage(false); + }; + + load(); }, []); async function onSubmit(data: GeneralFormValues) { @@ -263,391 +270,399 @@ export default function GeneralForm() { } return ( - - - - - General Settings - - - Configure the general settings for this resource - - + !loadingPage && ( + + + + + General Settings + + + Configure the general settings for this resource + + - - -
- - ( - - Name - - - - - - This is the display name of the - resource. - - - )} - /> - - {resource.http && ( - <> - {env.flags.allowBaseDomainResources && ( -
- { - setDomainType( - val as any - ); - form.setValue( - "isBaseDomain", - val === "basedomain" - ); - }} - > -
- - -
-
- - -
-
-
- )} - - {domainType === "subdomain" ? ( -
- {!env.flags - .allowBaseDomainResources && ( - - Subdomain - - )} -
-
- ( - - - - - - - )} - /> -
-
- ( - - - - - )} - /> -
-
-
- ) : ( - ( - - - - - )} - /> - )} - - )} - - {!resource.http && ( + + + + ( - - Port Number - + Name - - field.onChange( - e.target.value - ? parseInt( - e - .target - .value - ) - : null - ) - } - /> + - - This is the port that will - be used to access the - resource. - )} /> - )} - - - - - - - -
+ {resource.http && ( + <> + {env.flags + .allowBaseDomainResources && ( + ( + + + Domain Type + + + + + )} + /> + )} - - - - Transfer Resource - - - Transfer this resource to a different site - - - - - -
- - ( - - - Destination Site - - - - - - - - - - - - No sites found. - - - {sites.map( - (site) => ( - { - transferForm.setValue( - "siteId", - site.siteId - ); - setOpen( - false - ); - }} - > - { - site.name - } - + {domainType === "subdomain" ? ( +
+ + Subdomain + +
+
+ ( + + + + + + + )} + /> +
+
+ ( + + + + + )} + /> +
+
+
+ ) : ( + ( + + + Base Domain + + + + + )} + /> + )} +
+ )} - /> - - - - - - - - - + {!resource.http && ( + ( + + + Port Number + + + + field.onChange( + e.target + .value + ? parseInt( + e + .target + .value + ) + : null + ) + } + /> + + + + )} + /> + )} + + + + + + + + + + + + + + Transfer Resource + + + Transfer this resource to a different site + + + + + +
+ + ( + + + Destination Site + + + + + + + + + + + + No sites found. + + + {sites.map( + (site) => ( + { + transferForm.setValue( + "siteId", + site.siteId + ); + setOpen( + false + ); + }} + > + { + site.name + } + + + ) + )} + + + + + + + )} + /> + + +
+
+ + + + +
+ + ) ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 2a4d0e51..5ed7a1a5 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -94,7 +94,7 @@ enum RuleAction { enum RuleMatch { PATH = "Path", IP = "IP", - CIDR = "IP Range", + CIDR = "IP Range" } export default function ResourceRules(props: { @@ -623,7 +623,7 @@ export default function ResourceRules(props: { onSubmit={addRuleForm.handleSubmit(addRule)} className="space-y-4" > -
+
)} /> +
- diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index bd5778ef..e91be2f2 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -152,13 +152,15 @@ export default function CreateShareLinkForm({ if (res?.status === 200) { setResources( - res.data.data.resources.filter((r) => { - return r.http; - }).map((r) => ({ - resourceId: r.resourceId, - name: r.name, - resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/` - })) + res.data.data.resources + .filter((r) => { + return r.http; + }) + .map((r) => ({ + resourceId: r.resourceId, + name: r.name, + resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/` + })) ); } } @@ -274,7 +276,7 @@ export default function CreateShareLinkForm({ name="resourceId" render={({ field }) => ( - + Resource @@ -318,9 +320,7 @@ export default function CreateShareLinkForm({ r ) => ( ( - + - + @@ -383,66 +381,68 @@ export default function CreateShareLinkForm({ />
- -
- ( - - - - - )} - /> +
+ Expire In +
+ ( + + + + + )} + /> - ( - - - - - - - )} - /> + ( + + + + + + + )} + /> +
@@ -552,6 +552,9 @@ export default function CreateShareLinkForm({
+ + + - - - diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx index 5d5d341f..2acc1399 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx @@ -273,7 +273,21 @@ export default function ShareLinksTable({ } return "Never"; } + }, + { + id: "delete", + cell: ({ row }) => ( +
+ +
+ ) } + ]; return ( diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx index 0a4cca14..ad7697e8 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx @@ -41,6 +41,7 @@ import Link from "next/link"; import { ArrowUpRight, ChevronsUpDown, + Loader2, SquareArrowOutUpRight } from "lucide-react"; import { @@ -48,6 +49,7 @@ import { CollapsibleContent, CollapsibleTrigger } from "@app/components/ui/collapsible"; +import LoaderPlaceholder from "@app/components/PlaceHolderLoader"; const createSiteFormSchema = z.object({ name: z @@ -97,6 +99,8 @@ export default function CreateSiteForm({ const [siteDefaults, setSiteDefaults] = useState(null); + const [loadingPage, setLoadingPage] = useState(true); + const handleCheckboxChange = (checked: boolean) => { // setChecked?.(checked); setIsChecked(checked); @@ -121,27 +125,35 @@ export default function CreateSiteForm({ useEffect(() => { if (!open) return; - // reset all values - setLoading?.(false); - setIsLoading(false); - form.reset(); - setChecked?.(false); - setKeypair(null); - setSiteDefaults(null); + const load = async () => { + setLoadingPage(true); + // reset all values + setLoading?.(false); + setIsLoading(false); + form.reset(); + setChecked?.(false); + setKeypair(null); + setSiteDefaults(null); - const generatedKeypair = generateKeypair(); - setKeypair(generatedKeypair); + const generatedKeypair = generateKeypair(); + setKeypair(generatedKeypair); - api.get(`/org/${orgId}/pick-site-defaults`) - .catch((e) => { - // update the default value of the form to be local method - form.setValue("method", "local"); - }) - .then((res) => { - if (res && res.status === 200) { - setSiteDefaults(res.data.data); - } - }); + await api + .get(`/org/${orgId}/pick-site-defaults`) + .catch((e) => { + // update the default value of the form to be local method + form.setValue("method", "local"); + }) + .then((res) => { + if (res && res.status === 200) { + setSiteDefaults(res.data.data); + } + }); + + setLoadingPage(false); + }; + + load(); }, [open]); async function onSubmit(data: CreateSiteFormValues) { @@ -257,7 +269,9 @@ PersistentKeepalive = 5` const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`; - return ( + return loadingPage ? ( + + ) : (
- This is the the display name for the - site. + This is the the display name for the site. )} diff --git a/src/app/[orgId]/settings/sites/CreateSiteModal.tsx b/src/app/[orgId]/settings/sites/CreateSiteModal.tsx index fd6ff914..1666000d 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteModal.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteModal.tsx @@ -58,6 +58,9 @@ export default function CreateSiteFormModal({
+ + + - - - diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index d9d0ba03..9b56aaeb 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -268,7 +268,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { - diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index 7b0fa46d..509bd294 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -68,7 +68,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { -
+
{children} diff --git a/src/app/globals.css b/src/app/globals.css index bcb47510..d7349dd7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,7 +22,7 @@ --destructive: 0 84.2% 60.2%; --destructive-foreground: 60 9.1% 97.8%; --border: 20 5.9% 85%; - --input: 20 5.9% 85%; + --input: 20 5.9% 80%; --ring: 24.6 95% 53.1%; --radius: 0.75rem; --chart-1: 12 76% 61%; @@ -50,7 +50,7 @@ --destructive: 0 72.2% 50.6%; --destructive-foreground: 60 9.1% 97.8%; --border: 12 6.5% 25.0%; - --input: 12 6.5% 25.0%; + --input: 12 6.5% 30.0%; --ring: 20.5 90.2% 48.2%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 02a9daba..a892ccef 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -37,11 +37,11 @@ export default async function RootLayout({ > {/* Main content */} -
{children}
+
{children}
{/* Footer */} -