Merge branch 'dev' into multi-domain

This commit is contained in:
miloschwartz 2025-02-26 21:26:20 -05:00
commit e82df67063
No known key found for this signature in database
30 changed files with 1508 additions and 230 deletions

View file

@ -36,7 +36,7 @@ jobs:
run: | run: |
TAG=${{ env.TAG }} TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/ cat server/lib/consts.ts
- name: Pull latest Gerbil version - name: Pull latest Gerbil version
id: get-gerbil-tag id: get-gerbil-tag

View file

@ -9,6 +9,13 @@
</div> </div>
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
<div align="center">
_Your own self-hosted zero trust tunnel._
</div>
<div align="center"> <div align="center">
<h5> <h5>
<a href="https://docs.fossorial.io/Getting%20Started/quick-install"> <a href="https://docs.fossorial.io/Getting%20Started/quick-install">
@ -21,13 +28,6 @@
</h5> </h5>
</div> </div>
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
<div align="center">
_Your own self-hosted zero trust tunnel._
</div>
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. 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.
<img src="public/screenshots/sites.png" alt="Preview"/> <img src="public/screenshots/sites.png" alt="Preview"/>
@ -108,7 +108,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
1. **Deploy the Central Server**: 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**: 2. **Domain Configuration**:
@ -147,7 +151,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
## Licensing ## 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 ## Contributions

View file

@ -1,5 +1,4 @@
version: "3.7" name: pangolin
services: services:
pangolin: pangolin:
image: fosrl/pangolin:latest image: fosrl/pangolin:latest
@ -32,7 +31,6 @@ services:
- SYS_MODULE - SYS_MODULE
ports: ports:
- 51820:51820/udp - 51820:51820/udp
- 8080:8080 # Port for traefik because of the network_mode
- 443:443 # 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 - 80:80 # Port for traefik because of the network_mode
@ -47,8 +45,8 @@ services:
command: command:
- --configFile=/etc/traefik/traefik_config.yml - --configFile=/etc/traefik/traefik_config.yml
volumes: volumes:
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
networks: networks:
default: default:

View file

@ -1,13 +1,24 @@
all: build all: update-versions go-build-release put-back
build: go-build-release:
CGO_ENABLED=0 go build -o bin/installer
release:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64 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 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
clean: clean:
rm -f bin/installer
rm -f bin/installer_linux_amd64 rm -f bin/installer_linux_amd64
rm -f bin/installer_linux_arm64 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

353
install/config.go Normal file
View file

@ -0,0 +1,353 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
"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))
}
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)
updatedData, err := MarshalYAMLWithIndent(destCompose, 2)
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
}
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
}
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
}
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
}
// 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
}

View file

@ -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

View file

@ -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

View file

@ -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: "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)
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(`{{.DashboardDomain}}`)" # 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(`{{.DashboardDomain}}`) && !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(`{{.DashboardDomain}}`) && 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(`{{.DashboardDomain}}`)" # 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

View file

@ -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

View file

@ -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:
- crowdsec@file
serversTransport:
insecureSkipVerify: true

View file

@ -1,3 +1,4 @@
name: pangolin
services: services:
pangolin: pangolin:
image: fosrl/pangolin:{{.PangolinVersion}} image: fosrl/pangolin:{{.PangolinVersion}}
@ -10,7 +11,6 @@ services:
interval: "3s" interval: "3s"
timeout: "3s" timeout: "3s"
retries: 5 retries: 5
{{if .InstallGerbil}} {{if .InstallGerbil}}
gerbil: gerbil:
image: fosrl/gerbil:{{.GerbilVersion}} image: fosrl/gerbil:{{.GerbilVersion}}
@ -34,15 +34,13 @@ services:
- 443:443 # 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 - 80:80 # Port for traefik because of the network_mode
{{end}} {{end}}
traefik: traefik:
image: traefik:v3.3.3 image: traefik:v3.3.3
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
{{if .InstallGerbil}} {{if .InstallGerbil}}
network_mode: service:gerbil # Ports appear on the gerbil service network_mode: service:gerbil # Ports appear on the gerbil service
{{end}} {{end}}{{if not .InstallGerbil}}
{{if not .InstallGerbil}}
ports: ports:
- 443:443 - 443:443
- 80:80 - 80:80
@ -55,6 +53,7 @@ services:
volumes: volumes:
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
networks: networks:
default: default:

121
install/crowdsec.go Normal file
View file

@ -0,0 +1,121 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
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)
}
if err := createConfigFiles(config); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
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)
}
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 := 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)
}
// 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)
}
if err := os.Remove("config/crowdsec/docker-compose.yml"); err != nil {
fmt.Printf("Error removing file: %v\n", err)
os.Exit(1)
}
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)
}
// get API key
apiKey, err := GetCrowdSecAPIKey()
if err != nil {
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)
}
if err := restartContainer("traefik"); err != nil {
return fmt.Errorf("failed to restart containers: %v", err)
}
return nil
}
func checkIsCrowdsecInstalledInCompose() bool {
// Read docker-compose.yml
content, err := os.ReadFile("docker-compose.yml")
if err != nil {
return false
}
// 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
}

View file

@ -5,4 +5,5 @@ go 1.23.0
require ( require (
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect golang.org/x/term v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View file

@ -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/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 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 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=

12
install/input.txt Normal file
View file

@ -0,0 +1,12 @@
example.com
pangolin.example.com
admin@example.com
yes
admin@example.com
Password123!
Password123!
yes
no
no
no
yes

View file

@ -4,13 +4,16 @@ import (
"bufio" "bufio"
"embed" "embed"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"os" "os"
"time"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"syscall" "syscall"
"bytes"
"text/template" "text/template"
"unicode" "unicode"
@ -24,7 +27,7 @@ func loadVersions(config *Config) {
config.BadgerVersion = "replaceme" config.BadgerVersion = "replaceme"
} }
//go:embed fs/* //go:embed config/*
var configFiles embed.FS var configFiles embed.FS
type Config struct { type Config struct {
@ -45,6 +48,8 @@ type Config struct {
EmailSMTPPass string EmailSMTPPass string
EmailNoReply string EmailNoReply string
InstallGerbil bool InstallGerbil bool
TraefikBouncerKey string
DoCrowdsecInstall bool
} }
func main() { func main() {
@ -56,9 +61,12 @@ func main() {
os.Exit(1) os.Exit(1)
} }
var config Config
config.DoCrowdsecInstall = false
// check if there is already a config file // check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil { if _, err := os.Stat("config/config.yml"); err != nil {
config := collectUserInput(reader) config = collectUserInput(reader)
loadVersions(&config) loadVersions(&config)
@ -67,20 +75,55 @@ func main() {
os.Exit(1) os.Exit(1)
} }
moveFile("config/docker-compose.yml", "docker-compose.yml")
if !isDockerInstalled() && runtime.GOOS == "linux" { if !isDockerInstalled() && runtime.GOOS == "linux" {
if readBool(reader, "Docker is not installed. Would you like to install it?", true) { if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker() installDocker()
} }
} }
} else {
fmt.Println("Config file already exists... skipping configuration") fmt.Println("\n=== Starting installation ===")
}
if isDockerInstalled() { if isDockerInstalled() {
if readBool(reader, "Would you like to install and start the containers?", true) { if readBool(reader, "Would you like to install and start the containers?", true) {
pullAndStartContainers() pullAndStartContainers()
} }
} }
} else {
fmt.Println("Looks like you already installed, so I am going to do the setup...")
}
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)
}
}
fmt.Println("Installation complete!") fmt.Println("Installation complete!")
} }
@ -99,22 +142,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
return input return input
} }
func readPassword(prompt string) string { func readPassword(prompt string, reader *bufio.Reader) string {
if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print(prompt + ": ") fmt.Print(prompt + ": ")
// Read password without echo if we're in a terminal
// Read password without echo
password, err := term.ReadPassword(int(syscall.Stdin)) password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil { if err != nil {
return "" return ""
} }
input := strings.TrimSpace(string(password)) input := strings.TrimSpace(string(password))
if input == "" { if input == "" {
return readPassword(prompt) return readPassword(prompt, reader)
} }
return input 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 { func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
@ -150,8 +195,8 @@ func collectUserInput(reader *bufio.Reader) Config {
fmt.Println("\n=== Admin User Configuration ===") fmt.Println("\n=== Admin User Configuration ===")
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain) config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
for { for {
pass1 := readPassword("Create admin user password") pass1 := readPassword("Create admin user password", reader)
pass2 := readPassword("Confirm admin user password") pass2 := readPassword("Confirm admin user password", reader)
if pass1 != pass2 { if pass1 != pass2 {
fmt.Println("Passwords do not match") fmt.Println("Passwords do not match")
@ -261,31 +306,33 @@ func createConfigFiles(config Config) error {
os.MkdirAll("config/logs", 0755) os.MkdirAll("config/logs", 0755)
// Walk through all embedded files // 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 { if err != nil {
return err return err
} }
// Skip the root fs directory itself // Skip the root fs directory itself
if path == "fs" { if path == "config" {
return nil return nil
} }
// Get the relative path by removing the "fs/" prefix if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") {
relPath := strings.TrimPrefix(path, "fs/") return nil
}
if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") {
return nil
}
// skip .DS_Store // skip .DS_Store
if strings.Contains(relPath, ".DS_Store") { if strings.Contains(path, ".DS_Store") {
return nil return nil
} }
// Create the full output path under "config/"
outPath := filepath.Join("config", relPath)
if d.IsDir() { if d.IsDir() {
// Create directory // Create directory
if err := os.MkdirAll(outPath, 0755); err != nil { if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", outPath, err) return fmt.Errorf("failed to create directory %s: %v", path, err)
} }
return nil return nil
} }
@ -303,14 +350,14 @@ func createConfigFiles(config Config) error {
} }
// Ensure parent directory exists // Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err) return fmt.Errorf("failed to create parent directory for %s: %v", path, err)
} }
// Create output file // Create output file
outFile, err := os.Create(outPath) outFile, err := os.Create(path)
if err != nil { 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() defer outFile.Close()
@ -326,30 +373,10 @@ func createConfigFiles(config Config) error {
return fmt.Errorf("error walking config files: %v", err) 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 return nil
} }
func installDocker() error { func installDocker() error {
// Detect Linux distribution // Detect Linux distribution
cmd := exec.Command("cat", "/etc/os-release") cmd := exec.Command("cat", "/etc/os-release")
@ -490,3 +517,166 @@ func pullAndStartContainers() error {
return nil 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 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 {
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 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()))
}

View file

@ -11,7 +11,7 @@ import {
users users
} from "@server/db/schema"; } from "@server/db/schema";
import db from "@server/db"; import db from "@server/db";
import { eq } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random"; import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random";
@ -95,12 +95,36 @@ export async function validateSessionToken(
} }
export async function invalidateSession(sessionId: string): Promise<void> { export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(resourceSessions).where(eq(resourceSessions.userSessionId, sessionId)); try {
await db.delete(sessions).where(eq(sessions.sessionId, sessionId)); await db.transaction(async (trx) => {
await trx
.delete(resourceSessions)
.where(eq(resourceSessions.userSessionId, sessionId));
await trx.delete(sessions).where(eq(sessions.sessionId, sessionId));
});
} catch (e) {
logger.error("Failed to invalidate session", e);
}
} }
export async function invalidateAllSessions(userId: string): Promise<void> { export async function invalidateAllSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId)); try {
await db.transaction(async (trx) => {
const userSessions = await trx
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
await trx.delete(sessions).where(eq(sessions.userId, userId));
});
} catch (e) {
logger.error("Failed to all invalidate user sessions", e);
}
} }
export function serializeSessionCookie( export function serializeSessionCookie(

183
server/lib/ip.test.ts Normal file
View file

@ -0,0 +1,183 @@
import { cidrToRange, findNextAvailableCidr } from "./ip";
/**
* Compares two objects for deep equality
* @param actual The actual value to test
* @param expected The expected value to compare against
* @param message The message to display if assertion fails
* @throws Error if objects are not equal
*/
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
const actualStr = JSON.stringify(actual);
const expectedStr = JSON.stringify(expected);
if (actualStr !== expectedStr) {
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
}
}
/**
* Compares two primitive values for equality
* @param actual The actual value to test
* @param expected The expected value to compare against
* @param message The message to display if assertion fails
* @throws Error if values are not equal
*/
export function assertEquals<T>(actual: T, expected: T, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
}
}
/**
* Tests if a function throws an expected error
* @param fn The function to test
* @param expectedError The expected error message or part of it
* @param message The message to display if assertion fails
* @throws Error if function doesn't throw or throws unexpected error
*/
export function assertThrows(
fn: () => void,
expectedError: string,
message: string
): void {
try {
fn();
throw new Error(`${message}: Expected to throw "${expectedError}"`);
} catch (error) {
if (!(error instanceof Error)) {
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
}
if (!error.message.includes(expectedError)) {
throw new Error(
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
);
}
}
}
// Test cases
function testFindNextAvailableCidr() {
console.log("Running findNextAvailableCidr tests...");
// Test 1: Basic IPv4 allocation
{
const existing = ["10.0.0.0/16", "10.1.0.0/16"];
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed");
}
// Test 2: Finding gap between allocations
{
const existing = ["10.0.0.0/16", "10.2.0.0/16"];
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
assertEquals(result, "10.1.0.0/16", "Finding gap between allocations failed");
}
// Test 3: No available space
{
const existing = ["10.0.0.0/8"];
const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8");
assertEquals(result, null, "No available space test failed");
}
// // Test 4: IPv6 allocation
// {
// const existing = ["2001:db8::/32", "2001:db8:1::/32"];
// const result = findNextAvailableCidr(existing, 32, "2001:db8::/16");
// assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed");
// }
// // Test 5: Mixed IP versions
// {
// const existing = ["10.0.0.0/16", "2001:db8::/32"];
// assertThrows(
// () => findNextAvailableCidr(existing, 16),
// "All CIDRs must be of the same IP version",
// "Mixed IP versions test failed"
// );
// }
// Test 6: Empty input
{
const existing: string[] = [];
const result = findNextAvailableCidr(existing, 16);
assertEquals(result, null, "Empty input test failed");
}
// Test 7: Block size alignment
{
const existing = ["10.0.0.0/24"];
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
assertEquals(result, "10.0.1.0/24", "Block size alignment test failed");
}
// Test 8: Block size alignment
{
const existing: string[] = [];
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
assertEquals(result, "10.0.0.0/24", "Block size alignment test failed");
}
// Test 9: Large block size request
{
const existing = ["10.0.0.0/24", "10.0.1.0/24"];
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16");
assertEquals(result, null, "Large block size request test failed");
}
console.log("All findNextAvailableCidr tests passed!");
}
// function testCidrToRange() {
// console.log("Running cidrToRange tests...");
// // Test 1: Basic IPv4 conversion
// {
// const result = cidrToRange("192.168.0.0/24");
// assertEqualsObj(result, {
// start: BigInt("3232235520"),
// end: BigInt("3232235775")
// }, "Basic IPv4 conversion failed");
// }
// // Test 2: IPv6 conversion
// {
// const result = cidrToRange("2001:db8::/32");
// assertEqualsObj(result, {
// start: BigInt("42540766411282592856903984951653826560"),
// end: BigInt("42540766411282592875350729025363378175")
// }, "IPv6 conversion failed");
// }
// // Test 3: Invalid prefix length
// {
// assertThrows(
// () => cidrToRange("192.168.0.0/33"),
// "Invalid prefix length for IPv4",
// "Invalid IPv4 prefix test failed"
// );
// }
// // Test 4: Invalid IPv6 prefix
// {
// assertThrows(
// () => cidrToRange("2001:db8::/129"),
// "Invalid prefix length for IPv6",
// "Invalid IPv6 prefix test failed"
// );
// }
// console.log("All cidrToRange tests passed!");
// }
// Run all tests
try {
// testCidrToRange();
testFindNextAvailableCidr();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}

View file

@ -3,58 +3,162 @@ interface IPRange {
end: bigint; end: bigint;
} }
type IPVersion = 4 | 6;
/** /**
* Converts IP address string to BigInt for numerical operations * Detects IP version from address string
*/
function detectIpVersion(ip: string): IPVersion {
return ip.includes(':') ? 6 : 4;
}
/**
* Converts IPv4 or IPv6 address string to BigInt for numerical operations
*/ */
function ipToBigInt(ip: string): bigint { function ipToBigInt(ip: string): bigint {
const version = detectIpVersion(ip);
if (version === 4) {
return ip.split('.') return ip.split('.')
.reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0)); .reduce((acc, octet) => {
const num = parseInt(octet);
if (isNaN(num) || num < 0 || num > 255) {
throw new Error(`Invalid IPv4 octet: ${octet}`);
}
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
}, BigInt(0));
} else {
// Handle IPv6
// Expand :: notation
let fullAddress = ip;
if (ip.includes('::')) {
const parts = ip.split('::');
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
const padding = Array(missing).fill('0').join(':');
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
}
return fullAddress.split(':')
.reduce((acc, hextet) => {
const num = parseInt(hextet || '0', 16);
if (isNaN(num) || num < 0 || num > 65535) {
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
}
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
}, BigInt(0));
}
} }
/** /**
* Converts BigInt to IP address string * Converts BigInt to IP address string
*/ */
function bigIntToIp(num: bigint): string { function bigIntToIp(num: bigint, version: IPVersion): string {
if (version === 4) {
const octets: number[] = []; const octets: number[] = [];
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
octets.unshift(Number(num & BigInt(255))); octets.unshift(Number(num & BigInt(255)));
num = num >> BigInt(8); num = num >> BigInt(8);
} }
return octets.join('.'); return octets.join('.');
} else {
const hextets: string[] = [];
for (let i = 0; i < 8; i++) {
hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0'));
num = num >> BigInt(16);
}
// Compress zero sequences
let maxZeroStart = -1;
let maxZeroLength = 0;
let currentZeroStart = -1;
let currentZeroLength = 0;
for (let i = 0; i < hextets.length; i++) {
if (hextets[i] === '0000') {
if (currentZeroStart === -1) currentZeroStart = i;
currentZeroLength++;
if (currentZeroLength > maxZeroLength) {
maxZeroLength = currentZeroLength;
maxZeroStart = currentZeroStart;
}
} else {
currentZeroStart = -1;
currentZeroLength = 0;
}
}
if (maxZeroLength > 1) {
hextets.splice(maxZeroStart, maxZeroLength, '');
if (maxZeroStart === 0) hextets.unshift('');
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
}
return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':');
}
} }
/** /**
* Converts CIDR to IP range * Converts CIDR to IP range
*/ */
function cidrToRange(cidr: string): IPRange { export function cidrToRange(cidr: string): IPRange {
const [ip, prefix] = cidr.split('/'); const [ip, prefix] = cidr.split('/');
const version = detectIpVersion(ip);
const prefixBits = parseInt(prefix); const prefixBits = parseInt(prefix);
const ipBigInt = ipToBigInt(ip); const ipBigInt = ipToBigInt(ip);
const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1));
// Validate prefix length
const maxPrefix = version === 4 ? 32 : 128;
if (prefixBits < 0 || prefixBits > maxPrefix) {
throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`);
}
const shiftBits = BigInt(maxPrefix - prefixBits);
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
const start = ipBigInt & ~mask; const start = ipBigInt & ~mask;
const end = start | mask; const end = start | mask;
return { start, end }; return { start, end };
} }
/** /**
* Finds the next available CIDR block given existing allocations * Finds the next available CIDR block given existing allocations
* @param existingCidrs Array of existing CIDR blocks * @param existingCidrs Array of existing CIDR blocks
* @param blockSize Desired prefix length for the new block (e.g., 24 for /24) * @param blockSize Desired prefix length for the new block
* @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0") * @param startCidr Optional CIDR to start searching from
* @returns Next available CIDR block or null if none found * @returns Next available CIDR block or null if none found
*/ */
export function findNextAvailableCidr( export function findNextAvailableCidr(
existingCidrs: string[], existingCidrs: string[],
blockSize: number, blockSize: number,
startCidr: string = "0.0.0.0/0" startCidr?: string
): string | null { ): string | null {
if (!startCidr && existingCidrs.length === 0) {
return null;
}
// If no existing CIDRs, use the IP version from startCidr
const version = startCidr
? detectIpVersion(startCidr.split('/')[0])
: 4; // Default to IPv4 if no startCidr provided
// Use appropriate default startCidr if none provided
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
// If there are existing CIDRs, ensure all are same version
if (existingCidrs.length > 0 &&
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
throw new Error('All CIDRs must be of the same IP version');
}
// Convert existing CIDRs to ranges and sort them // Convert existing CIDRs to ranges and sort them
const existingRanges = existingCidrs const existingRanges = existingCidrs
.map(cidr => cidrToRange(cidr)) .map(cidr => cidrToRange(cidr))
.sort((a, b) => (a.start < b.start ? -1 : 1)); .sort((a, b) => (a.start < b.start ? -1 : 1));
// Calculate block size // Calculate block size
const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize); const maxPrefix = version === 4 ? 32 : 128;
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
// Start from the beginning of the given CIDR // Start from the beginning of the given CIDR
let current = cidrToRange(startCidr).start; let current = cidrToRange(startCidr).start;
@ -63,7 +167,6 @@ export function findNextAvailableCidr(
// Iterate through existing ranges // Iterate through existing ranges
for (let i = 0; i <= existingRanges.length; i++) { for (let i = 0; i <= existingRanges.length; i++) {
const nextRange = existingRanges[i]; const nextRange = existingRanges[i];
// Align current to block size // Align current to block size
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
@ -74,7 +177,7 @@ export function findNextAvailableCidr(
// If we're at the end of existing ranges or found a gap // If we're at the end of existing ranges or found a gap
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) { if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
return `${bigIntToIp(alignedCurrent)}/${blockSize}`; return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
} }
// Move current pointer to after the current range // Move current pointer to after the current range
@ -91,6 +194,13 @@ export function findNextAvailableCidr(
* @returns boolean indicating if IP is within the CIDR range * @returns boolean indicating if IP is within the CIDR range
*/ */
export function isIpInCidr(ip: string, cidr: string): boolean { export function isIpInCidr(ip: string, cidr: string): boolean {
const ipVersion = detectIpVersion(ip);
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
if (ipVersion !== cidrVersion) {
throw new Error('IP address and CIDR must be of the same version');
}
const ipBigInt = ipToBigInt(ip); const ipBigInt = ipToBigInt(ip);
const range = cidrToRange(cidr); const range = cidrToRange(cidr);
return ipBigInt >= range.start && ipBigInt <= range.end; return ipBigInt >= range.start && ipBigInt <= range.end;

View file

@ -24,7 +24,7 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
for (let i = 0; i < segments.length; i++) { for (let i = 0; i < segments.length; i++) {
const segment = segments[i]; const segment = segments[i];
// Empty segments are not allowed (double slashes) // Empty segments are not allowed (double slashes), except at the end
if (!segment && i !== segments.length - 1) { if (!segment && i !== segments.length - 1) {
return false; return false;
} }
@ -34,11 +34,63 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
return false; return false;
} }
// Check for invalid characters // Check each character in the segment
if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) { for (let j = 0; j < segment.length; j++) {
const char = segment[j];
// Check for percent-encoded sequences
if (char === "%" && j + 2 < segment.length) {
const hex1 = segment[j + 1];
const hex2 = segment[j + 2];
if (
!/^[0-9A-Fa-f]$/.test(hex1) ||
!/^[0-9A-Fa-f]$/.test(hex2)
) {
return false; return false;
} }
j += 2; // Skip the next two characters
continue;
}
// Allow:
// - unreserved (A-Z a-z 0-9 - . _ ~)
// - sub-delims (! $ & ' ( ) * + , ; =)
// - @ : for compatibility with some systems
if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) {
return false;
}
}
} }
return true; return true;
} }
export function isUrlValid(url: string | undefined) {
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
var pattern = new RegExp(
"^(https?:\\/\\/)?" + // protocol
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
"((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
"(\\#[-a-z\\d_]*)?$",
"i"
);
return !!pattern.test(url);
}
export function isTargetValid(value: string | undefined) {
if (!value) return true;
const DOMAIN_REGEX =
/^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
return DOMAIN_REGEX.test(value);
}

View file

@ -149,8 +149,6 @@ export async function resetPassword(
const passwordHash = await hashPassword(newPassword); const passwordHash = await hashPassword(newPassword);
await invalidateAllSessions(resetRequest[0].userId);
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx await trx
.update(users) .update(users)
@ -162,11 +160,21 @@ export async function resetPassword(
.where(eq(passwordResetTokens.email, email)); .where(eq(passwordResetTokens.email, email));
}); });
try {
await invalidateAllSessions(resetRequest[0].userId);
} catch (e) {
logger.error("Failed to invalidate user sessions", e);
}
try {
await sendEmail(ConfirmPasswordReset({ email }), { await sendEmail(ConfirmPasswordReset({ email }), {
from: config.getNoReplyEmail(), from: config.getNoReplyEmail(),
to: email, to: email,
subject: "Password Reset Confirmation" subject: "Password Reset Confirmation"
}); });
} catch (e) {
logger.error("Failed to send password reset confirmation email", e);
}
return response<ResetPasswordResponse>(res, { return response<ResetPasswordResponse>(res, {
data: null, data: null,

View file

@ -90,7 +90,15 @@ export async function verifyResourceSession(
const clientIp = requestIp?.split(":")[0]; 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: let resourceData:
| { | {
resource: Resource | null; resource: Resource | null;
@ -111,11 +119,11 @@ export async function verifyResourceSession(
resourcePassword, resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId) eq(resourcePassword.resourceId, resources.resourceId)
) )
.where(eq(resources.fullDomain, host)) .where(eq(resources.fullDomain, cleanHost))
.limit(1); .limit(1);
if (!result) { if (!result) {
logger.debug("Resource not found", host); logger.debug("Resource not found", cleanHost);
return notAllowed(res); return notAllowed(res);
} }
@ -131,7 +139,7 @@ export async function verifyResourceSession(
const { resource, pincode, password } = resourceData; const { resource, pincode, password } = resourceData;
if (!resource) { if (!resource) {
logger.debug("Resource not found", host); logger.debug("Resource not found", cleanHost);
return notAllowed(res); return notAllowed(res);
} }

View file

@ -12,34 +12,7 @@ import { fromError } from "zod-validation-error";
import { addTargets } from "../newt/targets"; import { addTargets } from "../newt/targets";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
const createTargetParamsSchema = z const createTargetParamsSchema = z
.object({ .object({
@ -52,7 +25,7 @@ const createTargetParamsSchema = z
const createTargetSchema = z const createTargetSchema = z
.object({ .object({
ip: domainSchema, ip: z.string().refine(isTargetValid),
method: z.string().optional().nullable(), method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
enabled: z.boolean().default(true) enabled: z.boolean().default(true)

View file

@ -11,34 +11,7 @@ import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets"; import { addTargets } from "../newt/targets";
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
const updateTargetParamsSchema = z const updateTargetParamsSchema = z
.object({ .object({
@ -48,7 +21,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z const updateTargetBodySchema = z
.object({ .object({
ip: domainSchema.optional(), ip: z.string().refine(isTargetValid),
method: z.string().min(1).max(10).optional().nullable(), method: z.string().min(1).max(10).optional().nullable(),
port: z.number().int().min(1).max(65535).optional(), port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional() enabled: z.boolean().optional()

View file

@ -8,14 +8,12 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { import {
GetResourceAuthInfoResponse,
GetResourceWhitelistResponse, GetResourceWhitelistResponse,
ListResourceRolesResponse, ListResourceRolesResponse,
ListResourceUsersResponse ListResourceUsersResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { set, z } from "zod"; import { set, z } from "zod";
// import { Tag } from "emblor";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { import {
@ -27,12 +25,8 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
// import { TagInput } from "emblor";
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListUsersResponse } from "@server/routers/user"; import { ListUsersResponse } from "@server/routers/user";
import { Switch } from "@app/components/ui/switch"; import { Binary, Key } from "lucide-react";
import { Label } from "@app/components/ui/label";
import { Binary, Key, ShieldCheck } from "lucide-react";
import SetResourcePasswordForm from "./SetResourcePasswordForm"; import SetResourcePasswordForm from "./SetResourcePasswordForm";
import SetResourcePincodeForm from "./SetResourcePincodeForm"; import SetResourcePincodeForm from "./SetResourcePincodeForm";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
@ -44,12 +38,12 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter SettingsSectionFooter
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation";
const UsersRolesFormSchema = z.object({ const UsersRolesFormSchema = z.object({
roles: z.array( roles: z.array(
@ -83,6 +77,7 @@ export default function ResourceAuthenticationPage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const router = useRouter();
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
@ -237,6 +232,7 @@ export default function ResourceAuthenticationPage() {
title: "Saved successfully", title: "Saved successfully",
description: "Whitelist settings have been saved" description: "Whitelist settings have been saved"
}); });
router.refresh();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
toast({ toast({
@ -284,6 +280,7 @@ export default function ResourceAuthenticationPage() {
title: "Saved successfully", title: "Saved successfully",
description: "Authentication settings have been saved" description: "Authentication settings have been saved"
}); });
router.refresh();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
toast({ toast({
@ -315,6 +312,7 @@ export default function ResourceAuthenticationPage() {
updateAuthInfo({ updateAuthInfo({
password: false password: false
}); });
router.refresh();
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
@ -345,6 +343,7 @@ export default function ResourceAuthenticationPage() {
updateAuthInfo({ updateAuthInfo({
pincode: false pincode: false
}); });
router.refresh();
}) })
.catch((e) => { .catch((e) => {
toast({ toast({

View file

@ -62,39 +62,11 @@ import {
SettingsSectionFooter SettingsSectionFooter
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useSiteContext } from "@app/hooks/useSiteContext"; import { useRouter } from "next/navigation";
import { InfoPopup } from "@app/components/ui/info-popup"; import { isTargetValid } from "@server/lib/validators";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
const addTargetSchema = z.object({ const addTargetSchema = z.object({
ip: domainSchema, ip: z.string().refine(isTargetValid),
method: z.string().nullable(), method: z.string().nullable(),
port: z.coerce.number().int().positive() port: z.coerce.number().int().positive()
// protocol: z.string(), // protocol: z.string(),
@ -125,6 +97,7 @@ export default function ReverseProxyTargets(props: {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
const router = useRouter();
const addTargetForm = useForm({ const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema), resolver: zodResolver(addTargetSchema),
@ -299,6 +272,7 @@ export default function ReverseProxyTargets(props: {
}); });
setTargetsToRemove([]); setTargetsToRemove([]);
router.refresh();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast({ toast({
@ -339,6 +313,7 @@ export default function ReverseProxyTargets(props: {
title: "SSL Configuration", title: "SSL Configuration",
description: "SSL configuration updated successfully" description: "SSL configuration updated successfully"
}); });
router.refresh();
} }
} }
@ -446,6 +421,7 @@ export default function ReverseProxyTargets(props: {
<SelectContent> <SelectContent>
<SelectItem value="http">http</SelectItem> <SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem> <SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) )
@ -547,6 +523,9 @@ export default function ReverseProxyTargets(props: {
<SelectItem value="https"> <SelectItem value="https">
https https
</SelectItem> </SelectItem>
<SelectItem value="h2c">
h2c
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>

View file

@ -71,6 +71,7 @@ import {
isValidUrlGlobPattern isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { useRouter } from "next/navigation";
// Schema for rule validation // Schema for rule validation
const addRuleSchema = z.object({ const addRuleSchema = z.object({
@ -91,9 +92,9 @@ enum RuleAction {
} }
enum RuleMatch { enum RuleMatch {
PATH = "Path",
IP = "IP", IP = "IP",
CIDR = "IP Range", CIDR = "IP Range",
PATH = "Path"
} }
export default function ResourceRules(props: { export default function ResourceRules(props: {
@ -107,6 +108,7 @@ export default function ResourceRules(props: {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules); const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
const router = useRouter();
const addRuleForm = useForm({ const addRuleForm = useForm({
resolver: zodResolver(addRuleSchema), resolver: zodResolver(addRuleSchema),
@ -253,6 +255,7 @@ export default function ResourceRules(props: {
title: "Enable Rules", title: "Enable Rules",
description: "Rule evaluation has been updated" description: "Rule evaluation has been updated"
}); });
router.refresh();
} }
} }
@ -370,6 +373,7 @@ export default function ResourceRules(props: {
}); });
setRulesToRemove([]); setRulesToRemove([]);
router.refresh();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast({ toast({
@ -465,9 +469,9 @@ export default function ResourceRules(props: {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem> <SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem> <SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) )
@ -596,7 +600,7 @@ export default function ResourceRules(props: {
<SwitchInput <SwitchInput
id="rules-toggle" id="rules-toggle"
label="Enable Rules" label="Enable Rules"
defaultChecked={resource.applyRules} defaultChecked={rulesEnabled}
onCheckedChange={async (val) => { onCheckedChange={async (val) => {
await saveApplyRules(val); await saveApplyRules(val);
}} }}
@ -667,17 +671,17 @@ export default function ResourceRules(props: {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
<SelectItem value="IP"> <SelectItem value="IP">
{RuleMatch.IP} {RuleMatch.IP}
</SelectItem> </SelectItem>
<SelectItem value="CIDR"> <SelectItem value="CIDR">
{RuleMatch.CIDR} {RuleMatch.CIDR}
</SelectItem> </SelectItem>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>