Compare commits

..

14 Commits

Author SHA1 Message Date
Owen Schwartz 706f91e829 New translations en-us.json (Norwegian Bokmal) 2026-02-24 19:58:52 -08:00
Owen Schwartz 84c7111427 New translations en-us.json (Chinese Simplified) 2026-02-24 19:58:50 -08:00
Owen Schwartz 9512a4ee71 New translations en-us.json (Turkish) 2026-02-24 19:58:49 -08:00
Owen Schwartz 22f0eb0d59 New translations en-us.json (Russian) 2026-02-24 19:58:48 -08:00
Owen Schwartz 77987924af New translations en-us.json (Portuguese) 2026-02-24 19:58:46 -08:00
Owen Schwartz 0547a4fde3 New translations en-us.json (Polish) 2026-02-24 19:58:44 -08:00
Owen Schwartz dd53d90eb5 New translations en-us.json (Dutch) 2026-02-24 19:58:43 -08:00
Owen Schwartz f9ab1627fd New translations en-us.json (Korean) 2026-02-24 19:58:41 -08:00
Owen Schwartz f18ae7f16d New translations en-us.json (Italian) 2026-02-24 19:58:40 -08:00
Owen Schwartz 54f3c0a0c4 New translations en-us.json (German) 2026-02-24 19:58:38 -08:00
Owen Schwartz dc03bf8674 New translations en-us.json (Czech) 2026-02-24 19:58:37 -08:00
Owen Schwartz afc28713e3 New translations en-us.json (Bulgarian) 2026-02-24 19:58:35 -08:00
Owen Schwartz 9ac7b28455 New translations en-us.json (Spanish) 2026-02-24 19:58:33 -08:00
Owen Schwartz b13495f1e0 New translations en-us.json (French) 2026-02-24 19:58:32 -08:00
94 changed files with 801 additions and 2432 deletions
+2 -2
View File
@@ -28,9 +28,9 @@ LICENSE
CONTRIBUTING.md
dist
.git
server/migrations/
migrations/
config/
build.ts
tsconfig.json
Dockerfile*
drizzle.config.ts
migrations/
+23 -15
View File
@@ -29,7 +29,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
@@ -62,7 +62,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Monitor storage space
run: |
@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Monitor storage space
run: |
@@ -149,7 +149,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -201,10 +201,10 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Log in to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Extract tag name
id: get-tag
@@ -289,14 +289,22 @@ jobs:
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
shell: bash
- name: Update install/main.go
run: |
PANGOLIN_VERSION=${{ env.TAG }}
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }}
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go
echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION"
cat install/main.go
shell: bash
- name: Build installer
working-directory: install
run: |
make go-build-release \
PANGOLIN_VERSION=${{ env.TAG }} \
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} \
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
shell: bash
make go-build-release
- name: Upload artifacts from /install/bin
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
@@ -407,7 +415,7 @@ jobs:
shell: bash
- name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -570,7 +578,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
+4 -4
View File
@@ -23,7 +23,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
@@ -54,7 +54,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download MaxMind GeoLite2 databases
env:
@@ -104,7 +104,7 @@ jobs:
fi
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
@@ -145,7 +145,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
days-before-stale: 14
days-before-close: 14
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Build Docker image sqlite
run: make dev-build-sqlite
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Build Docker image pg
run: make dev-build-pg
+5 -15
View File
@@ -1,9 +1,8 @@
# FROM node:24-slim AS base
FROM public.ecr.aws/docker/library/node:24-slim AS base
FROM node:24-alpine AS base
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache python3 make g++
COPY package*.json ./
@@ -24,20 +23,15 @@ RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
npm run build:cli && \
test -f dist/server.mjs
# Create placeholder files for MaxMind databases to avoid COPY errors
# Real files should be present for saas builds, placeholders for oss builds
RUN touch /app/GeoLite2-Country.mmdb /app/GeoLite2-ASN.mmdb
FROM base AS builder
RUN npm ci --omit=dev
# FROM node:24-slim AS runner
FROM public.ecr.aws/docker/library/node:24-slim AS runner
FROM node:24-alpine AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y curl tzdata && rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache curl tzdata
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
@@ -57,16 +51,12 @@ COPY public ./public
# Copy MaxMind databases for SaaS builds
ARG BUILD=oss
RUN mkdir -p ./maxmind
# Copy MaxMind databases (placeholders exist for oss builds, real files for saas)
# This is only for saas
COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb
COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb
# Remove MaxMind databases for non-saas builds (keep only for saas)
RUN if [ "$BUILD" != "saas" ]; then rm -rf ./maxmind; fi
# OCI Image Labels - Build Args for dynamic values
ARG VERSION="dev"
ARG REVISION=""
+1 -1
View File
@@ -3,7 +3,7 @@ import { db, orgs } from "@server/db";
import { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { generateCA } from "@server/lib/sshCA";
import { generateCA } from "@server/private/lib/sshCA";
import fs from "fs";
import yaml from "js-yaml";
+34 -17
View File
@@ -1,24 +1,41 @@
all: go-build-release
# Build with version injection via ldflags
# Versions can be passed via: make go-build-release PANGOLIN_VERSION=x.x.x GERBIL_VERSION=x.x.x BADGER_VERSION=x.x.x
# Or fetched automatically if not provided (requires curl and jq)
PANGOLIN_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name')
GERBIL_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name')
BADGER_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name')
LDFLAGS = -X main.pangolinVersion=$(PANGOLIN_VERSION) \
-X main.gerbilVersion=$(GERBIL_VERSION) \
-X main.badgerVersion=$(BADGER_VERSION)
all: update-versions go-build-release put-back
dev-all: dev-update-versions dev-build dev-clean
go-build-release:
@echo "Building with versions - Pangolin: $(PANGOLIN_VERSION), Gerbil: $(GERBIL_VERSION), Badger: $(BADGER_VERSION)"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_arm64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
clean:
rm -f bin/installer_linux_amd64
rm -f bin/installer_linux_arm64
.PHONY: all go-build-release clean
update-versions:
@echo "Fetching latest versions..."
cp main.go main.go.bak && \
$(MAKE) dev-update-versions
put-back:
mv main.go.bak main.go
dev-update-versions:
if [ -z "$(tag)" ]; then \
PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name'); \
else \
PANGOLIN_VERSION=$(tag); \
fi && \
GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
echo "Updated main.go with latest versions"
dev-build: go-build-release
dev-clean:
@echo "Restoring version values ..."
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"replaceme\"/" main.go && \
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"replaceme\"/" main.go && \
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"replaceme\"/" main.go
@echo "Restored version strings in main.go"
+20 -19
View File
@@ -118,19 +118,19 @@ func copyDockerService(sourceFile, destFile, serviceName string) error {
}
// Parse source Docker Compose YAML
var sourceCompose map[string]any
var sourceCompose map[string]interface{}
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
}
// Parse destination Docker Compose YAML
var destCompose map[string]any
var destCompose map[string]interface{}
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
}
// Get services section from source
sourceServices, ok := sourceCompose["services"].(map[string]any)
sourceServices, ok := sourceCompose["services"].(map[string]interface{})
if !ok {
return fmt.Errorf("services section not found in source file or has invalid format")
}
@@ -142,10 +142,10 @@ func copyDockerService(sourceFile, destFile, serviceName string) error {
}
// Get or create services section in destination
destServices, ok := destCompose["services"].(map[string]any)
destServices, ok := destCompose["services"].(map[string]interface{})
if !ok {
// If services section doesn't exist, create it
destServices = make(map[string]any)
destServices = make(map[string]interface{})
destCompose["services"] = destServices
}
@@ -187,12 +187,13 @@ func backupConfig() error {
return nil
}
func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) {
func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) {
buffer := new(bytes.Buffer)
encoder := yaml.NewEncoder(buffer)
encoder.SetIndent(indent)
if err := encoder.Encode(data); err != nil {
err := encoder.Encode(data)
if err != nil {
return nil, err
}
@@ -208,7 +209,7 @@ func replaceInFile(filepath, oldStr, newStr string) error {
}
// Replace the string
newContent := strings.ReplaceAll(string(content), oldStr, newStr)
newContent := strings.Replace(string(content), oldStr, newStr, -1)
// Write the modified content back to the file
err = os.WriteFile(filepath, []byte(newContent), 0644)
@@ -227,28 +228,28 @@ func CheckAndAddTraefikLogVolume(composePath string) error {
}
// Parse YAML into a generic map
var compose map[string]any
var compose map[string]interface{}
if err := yaml.Unmarshal(data, &compose); err != nil {
return fmt.Errorf("error parsing compose file: %w", err)
}
// Get services section
services, ok := compose["services"].(map[string]any)
services, ok := compose["services"].(map[string]interface{})
if !ok {
return fmt.Errorf("services section not found or invalid")
}
// Get traefik service
traefik, ok := services["traefik"].(map[string]any)
traefik, ok := services["traefik"].(map[string]interface{})
if !ok {
return fmt.Errorf("traefik service not found or invalid")
}
// Check volumes
logVolume := "./config/traefik/logs:/var/log/traefik"
var volumes []any
var volumes []interface{}
if existingVolumes, ok := traefik["volumes"].([]any); ok {
if existingVolumes, ok := traefik["volumes"].([]interface{}); ok {
// Check if volume already exists
for _, v := range existingVolumes {
if v.(string) == logVolume {
@@ -294,13 +295,13 @@ func MergeYAML(baseFile, overlayFile string) error {
}
// Parse base YAML into a map
var baseMap map[string]any
var baseMap map[string]interface{}
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
return fmt.Errorf("error parsing base YAML: %v", err)
}
// Parse overlay YAML into a map
var overlayMap map[string]any
var overlayMap map[string]interface{}
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
return fmt.Errorf("error parsing overlay YAML: %v", err)
}
@@ -323,8 +324,8 @@ func MergeYAML(baseFile, overlayFile string) error {
}
// mergeMap recursively merges two maps
func mergeMap(base, overlay map[string]any) map[string]any {
result := make(map[string]any)
func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
// Copy all key-values from base map
for k, v := range base {
@@ -335,8 +336,8 @@ func mergeMap(base, overlay map[string]any) map[string]any {
for k, v := range overlay {
// If both maps have the same key and both values are maps, merge recursively
if baseVal, ok := base[k]; ok {
if baseMap, isBaseMap := baseVal.(map[string]any); isBaseMap {
if overlayMap, isOverlayMap := v.(map[string]any); isOverlayMap {
if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap {
if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap {
result[k] = mergeMap(baseMap, overlayMap)
continue
}
+3 -1
View File
@@ -38,7 +38,9 @@ services:
image: docker.io/traefik:v3.6
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
{{if .InstallGerbil}}
network_mode: service:gerbil # Ports appear on the gerbil service
{{end}}{{if not .InstallGerbil}}
ports:
- 443:443
- 80:80
+6 -7
View File
@@ -144,13 +144,12 @@ func installDocker() error {
}
func startDockerService() error {
switch runtime.GOOS {
case "linux":
if runtime.GOOS == "linux" {
cmd := exec.Command("systemctl", "enable", "--now", "docker")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
case "darwin":
} else if runtime.GOOS == "darwin" {
// On macOS, Docker is usually started via the Docker Desktop application
fmt.Println("Please start Docker Desktop manually on macOS.")
return nil
@@ -303,7 +302,7 @@ func pullContainers(containerType SupportedContainer) error {
return nil
}
return fmt.Errorf("unsupported container type: %s", containerType)
return fmt.Errorf("Unsupported container type: %s", containerType)
}
// startContainers starts the containers using the appropriate command.
@@ -326,7 +325,7 @@ func startContainers(containerType SupportedContainer) error {
return nil
}
return fmt.Errorf("unsupported container type: %s", containerType)
return fmt.Errorf("Unsupported container type: %s", containerType)
}
// stopContainers stops the containers using the appropriate command.
@@ -348,7 +347,7 @@ func stopContainers(containerType SupportedContainer) error {
return nil
}
return fmt.Errorf("unsupported container type: %s", containerType)
return fmt.Errorf("Unsupported container type: %s", containerType)
}
// restartContainer restarts a specific container using the appropriate command.
@@ -370,5 +369,5 @@ func restartContainer(container string, containerType SupportedContainer) error
return nil
}
return fmt.Errorf("unsupported container type: %s", containerType)
return fmt.Errorf("Unsupported container type: %s", containerType)
}
+10 -19
View File
@@ -27,18 +27,9 @@ func installCrowdsec(config Config) error {
os.Exit(1)
}
if err := os.MkdirAll("config/crowdsec/db", 0755); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1)
}
if err := os.MkdirAll("config/crowdsec/acquis.d", 0755); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1)
}
if err := os.MkdirAll("config/traefik/logs", 0755); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1)
}
os.MkdirAll("config/crowdsec/db", 0755)
os.MkdirAll("config/crowdsec/acquis.d", 0755)
os.MkdirAll("config/traefik/logs", 0755)
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
fmt.Printf("Error copying docker service: %v\n", err)
@@ -162,34 +153,34 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
}
// Parse YAML into a generic map
var compose map[string]any
var compose map[string]interface{}
if err := yaml.Unmarshal(data, &compose); err != nil {
return fmt.Errorf("error parsing compose file: %w", err)
}
// Get services section
services, ok := compose["services"].(map[string]any)
services, ok := compose["services"].(map[string]interface{})
if !ok {
return fmt.Errorf("services section not found or invalid")
}
// Get traefik service
traefik, ok := services["traefik"].(map[string]any)
traefik, ok := services["traefik"].(map[string]interface{})
if !ok {
return fmt.Errorf("traefik service not found or invalid")
}
// Get dependencies
dependsOn, ok := traefik["depends_on"].(map[string]any)
dependsOn, ok := traefik["depends_on"].(map[string]interface{})
if ok {
// Append the new block for crowdsec
dependsOn["crowdsec"] = map[string]any{
dependsOn["crowdsec"] = map[string]interface{}{
"condition": "service_healthy",
}
} else {
// No dependencies exist, create it
traefik["depends_on"] = map[string]any{
"crowdsec": map[string]any{
traefik["depends_on"] = map[string]interface{}{
"crowdsec": map[string]interface{}{
"condition": "service_healthy",
},
}
+2 -30
View File
@@ -3,36 +3,8 @@ module installer
go 1.24.0
require (
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.40.0
golang.org/x/term v0.39.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.23.0 // indirect
)
require golang.org/x/sys v0.40.0 // indirect
+4 -77
View File
@@ -1,80 +1,7 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+75 -218
View File
@@ -1,235 +1,92 @@
package main
import (
"errors"
"bufio"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"github.com/charmbracelet/huh"
"golang.org/x/term"
)
// pangolinTheme is the custom theme using brand colors
var pangolinTheme = ThemePangolin()
// isAccessibleMode checks if we should use accessible mode (simple prompts)
// This is true for: non-TTY, TERM=dumb, or ACCESSIBLE env var set
func isAccessibleMode() bool {
// Check if stdin is not a terminal (piped input, CI, etc.)
if !term.IsTerminal(int(os.Stdin.Fd())) {
return true
}
// Check for dumb terminal
if os.Getenv("TERM") == "dumb" {
return true
}
// Check for explicit accessible mode request
if os.Getenv("ACCESSIBLE") != "" {
return true
}
return false
}
// handleAbort checks if the error is a user abort (Ctrl+C) and exits if so
func handleAbort(err error) {
if err != nil && errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\nInstallation cancelled.")
os.Exit(0)
}
}
// runField runs a single field with the Pangolin theme, handling accessible mode
func runField(field huh.Field) error {
if isAccessibleMode() {
return field.RunAccessible(os.Stdout, os.Stdin)
}
form := huh.NewForm(huh.NewGroup(field)).WithTheme(pangolinTheme)
return form.Run()
}
func readString(prompt string, defaultValue string) string {
var value string
title := prompt
func readString(reader *bufio.Reader, prompt string, defaultValue string) string {
if defaultValue != "" {
title = fmt.Sprintf("%s (default: %s)", prompt, defaultValue)
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
} else {
fmt.Print(prompt + ": ")
}
input := huh.NewInput().
Title(title).
Value(&value)
// If no default value, this field is required
if defaultValue == "" {
input = input.Validate(func(s string) error {
if s == "" {
return fmt.Errorf("this field is required")
}
return nil
})
}
err := runField(input)
handleAbort(err)
if value == "" {
value = defaultValue
}
// Print the answer so it remains visible in terminal history (skip in accessible mode as it already shows)
if !isAccessibleMode() {
fmt.Printf("%s: %s\n", prompt, value)
}
return value
}
func readStringNoDefault(prompt string) string {
var value string
for {
input := huh.NewInput().
Title(prompt).
Value(&value).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("this field is required")
}
return nil
})
err := runField(input)
handleAbort(err)
if value != "" {
// Print the answer so it remains visible in terminal history
if !isAccessibleMode() {
fmt.Printf("%s: %s\n", prompt, value)
}
return value
}
}
}
func readPassword(prompt string) string {
var value string
for {
input := huh.NewInput().
Title(prompt).
Value(&value).
EchoMode(huh.EchoModePassword).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("password is required")
}
return nil
})
err := runField(input)
handleAbort(err)
if value != "" {
// Print confirmation without revealing the password
if !isAccessibleMode() {
fmt.Printf("%s: %s\n", prompt, "********")
}
return value
}
}
}
func readBool(prompt string, defaultValue bool) bool {
var value = defaultValue
confirm := huh.NewConfirm().
Title(prompt).
Value(&value).
Affirmative("Yes").
Negative("No")
err := runField(confirm)
handleAbort(err)
// Print the answer so it remains visible in terminal history
if !isAccessibleMode() {
answer := "No"
if value {
answer = "Yes"
}
fmt.Printf("%s: %s\n", prompt, answer)
}
return value
}
func readBoolNoDefault(prompt string) bool {
var value bool
confirm := huh.NewConfirm().
Title(prompt).
Value(&value).
Affirmative("Yes").
Negative("No")
err := runField(confirm)
handleAbort(err)
// Print the answer so it remains visible in terminal history
if !isAccessibleMode() {
answer := "No"
if value {
answer = "Yes"
}
fmt.Printf("%s: %s\n", prompt, answer)
}
return value
}
func readInt(prompt string, defaultValue int) int {
var value string
title := fmt.Sprintf("%s (default: %d)", prompt, defaultValue)
input := huh.NewInput().
Title(title).
Value(&value).
Validate(func(s string) error {
if s == "" {
return nil
}
_, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("please enter a valid number")
}
return nil
})
err := runField(input)
handleAbort(err)
if value == "" {
// Print the answer so it remains visible in terminal history
if !isAccessibleMode() {
fmt.Printf("%s: %d\n", prompt, defaultValue)
}
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return defaultValue
}
return input
}
result, err := strconv.Atoi(value)
if err != nil {
if !isAccessibleMode() {
fmt.Printf("%s: %d\n", prompt, defaultValue)
func readStringNoDefault(reader *bufio.Reader, prompt string) string {
fmt.Print(prompt + ": ")
input, _ := reader.ReadString('\n')
return strings.TrimSpace(input)
}
func readPassword(prompt string, reader *bufio.Reader) string {
if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print(prompt + ": ")
// Read password without echo if we're in a terminal
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil {
return ""
}
input := strings.TrimSpace(string(password))
if input == "" {
return readPassword(prompt, reader)
}
return input
} else {
// Fallback to reading from stdin if not in a terminal
return readString(reader, prompt, "")
}
}
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
defaultStr := "no"
if defaultValue {
defaultStr = "yes"
}
for {
input := readString(reader, prompt+" (yes/no)", defaultStr)
lower := strings.ToLower(input)
if lower == "yes" {
return true
} else if lower == "no" {
return false
} else {
fmt.Println("Please enter 'yes' or 'no'.")
}
}
}
func readBoolNoDefault(reader *bufio.Reader, prompt string) bool {
for {
input := readStringNoDefault(reader, prompt+" (yes/no)")
lower := strings.ToLower(input)
if lower == "yes" {
return true
} else if lower == "no" {
return false
} else {
fmt.Println("Please enter 'yes' or 'no'.")
}
}
}
func readInt(reader *bufio.Reader, prompt string, defaultValue int) int {
input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue))
if input == "" {
return defaultValue
}
// Print the answer so it remains visible in terminal history
if !isAccessibleMode() {
fmt.Printf("%s: %d\n", prompt, result)
}
return result
value := defaultValue
fmt.Sscanf(input, "%d", &value)
return value
}
+58 -76
View File
@@ -1,12 +1,13 @@
package main
import (
"crypto/rand"
"bufio"
"embed"
"encoding/base64"
"fmt"
"io"
"io/fs"
"crypto/rand"
"encoding/base64"
"net"
"net/http"
"net/url"
@@ -19,17 +20,11 @@ import (
"time"
)
// Version variables injected at build time via -ldflags
var (
pangolinVersion string
gerbilVersion string
badgerVersion string
)
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
func loadVersions(config *Config) {
config.PangolinVersion = pangolinVersion
config.GerbilVersion = gerbilVersion
config.BadgerVersion = badgerVersion
config.PangolinVersion = "replaceme"
config.GerbilVersion = "replaceme"
config.BadgerVersion = "replaceme"
}
//go:embed config/*
@@ -87,12 +82,14 @@ func main() {
}
}
reader := bufio.NewReader(os.Stdin)
var config Config
var alreadyInstalled = false
// check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil {
config = collectUserInput()
config = collectUserInput(reader)
loadVersions(&config)
config.DoCrowdsecInstall = false
@@ -105,10 +102,7 @@ func main() {
os.Exit(1)
}
if err := moveFile("config/docker-compose.yml", "docker-compose.yml"); err != nil {
fmt.Printf("Error moving docker-compose.yml: %v\n", err)
os.Exit(1)
}
moveFile("config/docker-compose.yml", "docker-compose.yml")
fmt.Println("\nConfiguration files created successfully!")
@@ -123,17 +117,13 @@ func main() {
fmt.Println("\n=== Starting installation ===")
if readBool("Would you like to install and start the containers?", true) {
if readBool(reader, "Would you like to install and start the containers?", true) {
config.InstallationContainerType = podmanOrDocker()
config.InstallationContainerType = podmanOrDocker(reader)
if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker {
if readBool("Docker is not installed. Would you like to install it?", true) {
if err := installDocker(); err != nil {
fmt.Printf("Error installing Docker: %v\n", err)
return
}
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker()
// try to start docker service but ignore errors
if err := startDockerService(); err != nil {
fmt.Println("Error starting Docker service:", err)
@@ -142,7 +132,7 @@ func main() {
}
// wait 10 seconds for docker to start checking if docker is running every 2 seconds
fmt.Println("Waiting for Docker to start...")
for range 5 {
for i := 0; i < 5; i++ {
if isDockerRunning() {
fmt.Println("Docker is running!")
break
@@ -177,7 +167,7 @@ func main() {
fmt.Println("\n=== MaxMind Database Update ===")
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
fmt.Println("MaxMind GeoLite2 Country database found.")
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
if readBool(reader, "Would you like to update the MaxMind database to the latest version?", false) {
if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error updating MaxMind database: %v\n", err)
fmt.Println("You can try updating it manually later if needed.")
@@ -185,7 +175,7 @@ func main() {
}
} else {
fmt.Println("MaxMind GeoLite2 Country database not found.")
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
if readBool(reader, "Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Println("You can try downloading it manually later if needed.")
@@ -202,11 +192,11 @@ func main() {
if !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== CrowdSec Install ===")
// check if crowdsec is installed
if readBool("Would you like to install CrowdSec?", false) {
if readBool(reader, "Would you like to install CrowdSec?", false) {
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
// BUG: crowdsec installation will be skipped if the user chooses to install on the first installation.
if readBool("Are you willing to manage CrowdSec?", false) {
if readBool(reader, "Are you willing to manage CrowdSec?", false) {
if config.DashboardDomain == "" {
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml")
if err != nil {
@@ -235,8 +225,8 @@ func main() {
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
if !readBool("Are these values correct?", true) {
config = collectUserInput()
if !readBool(reader, "Are these values correct?", true) {
config = collectUserInput(reader)
}
}
@@ -245,7 +235,7 @@ func main() {
if detectedType == Undefined {
// If detection fails, prompt the user
fmt.Println("Unable to detect container type from existing installation.")
config.InstallationContainerType = podmanOrDocker()
config.InstallationContainerType = podmanOrDocker(reader)
} else {
config.InstallationContainerType = detectedType
fmt.Printf("Detected container type: %s\n", config.InstallationContainerType)
@@ -287,8 +277,8 @@ func main() {
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
}
func podmanOrDocker() SupportedContainer {
inputContainer := readString("Would you like to run Pangolin as Docker or Podman containers?", "docker")
func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker")
chosenContainer := Docker
if strings.EqualFold(inputContainer, "docker") {
@@ -300,8 +290,7 @@ func podmanOrDocker() SupportedContainer {
os.Exit(1)
}
switch chosenContainer {
case Podman:
if chosenContainer == Podman {
if !isPodmanInstalled() {
fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.")
os.Exit(1)
@@ -310,7 +299,7 @@ func podmanOrDocker() SupportedContainer {
if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil {
fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.")
fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.")
approved := readBool("The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true)
approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true)
if approved {
if os.Geteuid() != 0 {
fmt.Println("You need to run the installer as root for such a configuration.")
@@ -322,7 +311,7 @@ func podmanOrDocker() SupportedContainer {
// Linux only.
if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system"); err != nil {
fmt.Printf("Error configuring unprivileged ports: %v\n", err)
fmt.Printf("Error configuring unprivileged ports: %v\n", err)
os.Exit(1)
}
} else {
@@ -332,7 +321,7 @@ func podmanOrDocker() SupportedContainer {
fmt.Println("Unprivileged ports have been configured.")
}
case Docker:
} else if chosenContainer == Docker {
// check if docker is not installed and the user is root
if !isDockerInstalled() {
if os.Geteuid() != 0 {
@@ -347,7 +336,7 @@ func podmanOrDocker() SupportedContainer {
fmt.Println("The installer will not be able to run docker commands without running it as root.")
os.Exit(1)
}
default:
} else {
// This shouldn't happen unless there's a third container runtime.
os.Exit(1)
}
@@ -355,35 +344,35 @@ func podmanOrDocker() SupportedContainer {
return chosenContainer
}
func collectUserInput() Config {
func collectUserInput(reader *bufio.Reader) Config {
config := Config{}
// Basic configuration
fmt.Println("\n=== Basic Configuration ===")
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
config.IsEnterprise = readBoolNoDefault(reader, "Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
// Set default dashboard domain after base domain is collected
defaultDashboardDomain := ""
if config.BaseDomain != "" {
defaultDashboardDomain = "pangolin." + config.BaseDomain
}
config.DashboardDomain = readString("Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
config.LetsEncryptEmail = readString("Enter email for Let's Encrypt certificates", "")
config.InstallGerbil = readBool("Do you want to use Gerbil to allow tunneled connections", true)
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
// Email configuration
fmt.Println("\n=== Email Configuration ===")
config.EnableEmail = readBool("Enable email functionality (SMTP)", false)
config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false)
if config.EnableEmail {
config.EmailSMTPHost = readString("Enter SMTP host", "")
config.EmailSMTPPort = readInt("Enter SMTP port (default 587)", 587)
config.EmailSMTPUser = readString("Enter SMTP username", "")
config.EmailSMTPPass = readPassword("Enter SMTP password")
config.EmailNoReply = readString("Enter no-reply email address (often the same as SMTP username)", "")
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
config.EmailSMTPUser = readString(reader, "Enter SMTP username", "")
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword?
config.EmailNoReply = readString(reader, "Enter no-reply email address (often the same as SMTP username)", "")
}
// Validate required fields
@@ -404,8 +393,8 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===")
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true)
config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required")
@@ -416,18 +405,10 @@ func collectUserInput() Config {
}
func createConfigFiles(config Config) error {
if err := os.MkdirAll("config", 0755); err != nil {
return fmt.Errorf("failed to create config directory: %v", err)
}
if err := os.MkdirAll("config/letsencrypt", 0755); err != nil {
return fmt.Errorf("failed to create letsencrypt directory: %v", err)
}
if err := os.MkdirAll("config/db", 0755); err != nil {
return fmt.Errorf("failed to create db directory: %v", err)
}
if err := os.MkdirAll("config/logs", 0755); err != nil {
return fmt.Errorf("failed to create logs directory: %v", err)
}
os.MkdirAll("config", 0755)
os.MkdirAll("config/letsencrypt", 0755)
os.MkdirAll("config/db", 0755)
os.MkdirAll("config/logs", 0755)
// Walk through all embedded files
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
@@ -581,24 +562,22 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
fmt.Println("To get your setup token, you need to:")
fmt.Println("")
fmt.Println("1. Start the containers")
switch containerType {
case Docker:
if containerType == Docker {
fmt.Println(" docker compose up -d")
case Podman:
} else if containerType == Podman {
fmt.Println(" podman-compose up -d")
} else {
}
fmt.Println("")
fmt.Println("2. Wait for the Pangolin container to start and generate the token")
fmt.Println("")
fmt.Println("3. Check the container logs for the setup token")
switch containerType {
case Docker:
if containerType == Docker {
fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
case Podman:
} else if containerType == Podman {
fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
} else {
}
fmt.Println("")
fmt.Println("4. Look for output like")
fmt.Println(" === SETUP TOKEN GENERATED ===")
@@ -660,7 +639,10 @@ func checkPortsAvailable(port int) error {
addr := fmt.Sprintf(":%d", port)
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("ERROR: port %d is occupied or cannot be bound: %w", port, err)
return fmt.Errorf(
"ERROR: port %d is occupied or cannot be bound: %w\n\n",
port, err,
)
}
if closeErr := ln.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr,
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// Pangolin brand colors (converted from oklch to hex)
var (
// Primary orange/amber - oklch(0.6717 0.1946 41.93)
primaryColor = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"}
// Muted foreground
mutedColor = lipgloss.AdaptiveColor{Light: "#737373", Dark: "#A3A3A3"}
// Success green
successColor = lipgloss.AdaptiveColor{Light: "#16A34A", Dark: "#22C55E"}
// Error red - oklch(0.577 0.245 27.325)
errorColor = lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#EF4444"}
// Normal text
normalFg = lipgloss.AdaptiveColor{Light: "#171717", Dark: "#FAFAFA"}
)
// ThemePangolin returns a huh theme using Pangolin brand colors
func ThemePangolin() *huh.Theme {
t := huh.ThemeBase()
// Focused state styles
t.Focused.Base = t.Focused.Base.BorderForeground(primaryColor)
t.Focused.Title = t.Focused.Title.Foreground(primaryColor).Bold(true)
t.Focused.Description = t.Focused.Description.Foreground(mutedColor)
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errorColor)
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errorColor)
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(primaryColor)
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(primaryColor)
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(primaryColor)
t.Focused.Option = t.Focused.Option.Foreground(normalFg)
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(primaryColor)
t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(successColor).SetString("✓ ")
t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(mutedColor).SetString(" ")
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(primaryColor)
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#404040"})
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primaryColor)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(primaryColor)
// Blurred state inherits from focused but with hidden border
t.Blurred = t.Focused
t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
t.Blurred.Title = t.Blurred.Title.Foreground(mutedColor).Bold(false)
t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(mutedColor)
return t
}
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Контрол на достъпа, базиран на потребители и роли",
"resourcesErrorUpdate": "Неуспешно превключване на ресурса",
"resourcesErrorUpdateDescription": "Възникна грешка при актуализиране на ресурса",
"access": "Достъп",
"access": "Access",
"accessControl": "Контрол на достъпа",
"shareLink": "{resource} Сподели връзка",
"resourceSelect": "Изберете ресурс",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Kontrola přístupu na základě uživatelů a rolí",
"resourcesErrorUpdate": "Nepodařilo se přepnout zdroj",
"resourcesErrorUpdateDescription": "Došlo k chybě při aktualizaci zdroje",
"access": "Přístup",
"access": "Access",
"accessControl": "Kontrola přístupu",
"shareLink": "{resource} Sdílet odkaz",
"resourceSelect": "Vyberte zdroj",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Benutzer- und rollenbasierte Zugriffskontrolle",
"resourcesErrorUpdate": "Fehler beim Umschalten der Ressource",
"resourcesErrorUpdateDescription": "Beim Aktualisieren der Ressource ist ein Fehler aufgetreten",
"access": "Zugriff",
"access": "Access",
"accessControl": "Zugriffskontrolle",
"shareLink": "{resource} Freigabe-Link",
"resourceSelect": "Ressource auswählen",
+2 -3
View File
@@ -649,8 +649,7 @@
"resourcesUsersRolesAccess": "User and role-based access control",
"resourcesErrorUpdate": "Failed to toggle resource",
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
"access": "Access",
"accessControl": "Access Control",
"access": "Access Control",
"shareLink": "{resource} Share Link",
"resourceSelect": "Select resource",
"shareLinks": "Share Links",
@@ -2543,7 +2542,7 @@
"internalResourceAuthDaemonSite": "On Site",
"internalResourceAuthDaemonSiteDescription": "Auth daemon runs on the site (Newt).",
"internalResourceAuthDaemonRemote": "Remote Host",
"internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on this resource's destination - not the site.",
"internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on a host that is not the site.",
"internalResourceAuthDaemonPort": "Daemon Port (optional)",
"orgAuthWhatsThis": "Where can I find my organization ID?",
"learnMore": "Learn more",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Control de acceso basado en usuarios y roles",
"resourcesErrorUpdate": "Error al cambiar el recurso",
"resourcesErrorUpdateDescription": "Se ha producido un error al actualizar el recurso",
"access": "Acceder",
"access": "Access",
"accessControl": "Control de acceso",
"shareLink": "{resource} Compartir Enlace",
"resourceSelect": "Seleccionar recurso",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Contrôle d'accès basé sur les utilisateurs et les rôles",
"resourcesErrorUpdate": "Échec de la bascule de la ressource",
"resourcesErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour de la ressource",
"access": "Accès",
"access": "Access",
"accessControl": "Contrôle d'accès",
"shareLink": "Lien de partage {resource}",
"resourceSelect": "Sélectionner une ressource",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Controllo accessi basato su utenti e ruoli",
"resourcesErrorUpdate": "Impossibile attivare/disattivare la risorsa",
"resourcesErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento della risorsa",
"access": "Accesso",
"access": "Access",
"accessControl": "Controllo Accessi",
"shareLink": "Link di Condivisione {resource}",
"resourceSelect": "Seleziona risorsa",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "사용자 및 역할 기반 접근 제어",
"resourcesErrorUpdate": "리소스를 전환하는 데 실패했습니다.",
"resourcesErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.",
"access": "접속",
"access": "Access",
"accessControl": "액세스 제어",
"shareLink": "{resource} 공유 링크",
"resourceSelect": "리소스 선택",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Bruker- og rollebasert tilgangskontroll",
"resourcesErrorUpdate": "Feilet å slå av/på ressurs",
"resourcesErrorUpdateDescription": "En feil oppstod under oppdatering av ressursen",
"access": "Tilgang",
"access": "Access",
"accessControl": "Tilgangskontroll",
"shareLink": "{resource} Del Lenke",
"resourceSelect": "Velg ressurs",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Gebruiker en rol-gebaseerde toegangsbeheer",
"resourcesErrorUpdate": "Bron wisselen mislukt",
"resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document",
"access": "Toegangsrechten",
"access": "Access",
"accessControl": "Toegangs controle",
"shareLink": "{resource} Share link",
"resourceSelect": "Selecteer resource",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Kontrola dostępu oparta na użytkownikach i rolach",
"resourcesErrorUpdate": "Nie udało się przełączyć zasobu",
"resourcesErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji zasobu",
"access": "Dostęp",
"access": "Access",
"accessControl": "Kontrola dostępu",
"shareLink": "Link udostępniania {resource}",
"resourceSelect": "Wybierz zasób",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Controle de acesso baseado em utilizadores e funções",
"resourcesErrorUpdate": "Falha ao alternar recurso",
"resourcesErrorUpdateDescription": "Ocorreu um erro ao atualizar o recurso",
"access": "Acesso",
"access": "Access",
"accessControl": "Controle de Acesso",
"shareLink": "Link de Compartilhamento {resource}",
"resourceSelect": "Selecionar recurso",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Контроль доступа на основе пользователей и ролей",
"resourcesErrorUpdate": "Не удалось переключить ресурс",
"resourcesErrorUpdateDescription": "Произошла ошибка при обновлении ресурса",
"access": "Доступ",
"access": "Access",
"accessControl": "Контроль доступа",
"shareLink": "Общая ссылка {resource}",
"resourceSelect": "Выберите ресурс",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "Kullanıcı ve rol tabanlı erişim kontrolü",
"resourcesErrorUpdate": "Kaynak değiştirilemedi",
"resourcesErrorUpdateDescription": "Kaynak güncellenirken bir hata oluştu",
"access": "Erişim",
"access": "Access",
"accessControl": "Erişim Kontrolü",
"shareLink": "{resource} Paylaşım Bağlantısı",
"resourceSelect": "Kaynak seçin",
+1 -1
View File
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "基于用户和角色的访问控制",
"resourcesErrorUpdate": "切换资源失败",
"resourcesErrorUpdateDescription": "更新资源时出错",
"access": "访问权限",
"access": "Access",
"accessControl": "访问控制",
"shareLink": "{resource} 的分享链接",
"resourceSelect": "选择资源",
+271 -238
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -32,7 +32,7 @@
"format": "prettier --write ."
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@asteasolutions/zod-to-openapi": "8.4.0",
"@aws-sdk/client-s3": "3.989.0",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
@@ -59,11 +59,11 @@
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@react-email/components": "1.0.8",
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@react-email/tailwind": "2.0.5",
"@react-email/tailwind": "2.0.4",
"@simplewebauthn/browser": "13.2.2",
"@simplewebauthn/server": "13.2.3",
"@simplewebauthn/server": "13.2.2",
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
@@ -81,7 +81,7 @@
"drizzle-orm": "0.45.1",
"express": "5.2.1",
"express-rate-limit": "8.2.1",
"glob": "13.0.6",
"glob": "13.0.3",
"helmet": "8.1.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
@@ -93,20 +93,20 @@
"maxmind": "5.0.5",
"moment": "2.30.1",
"next": "15.5.12",
"next-intl": "4.8.3",
"next-intl": "4.8.2",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "8.0.1",
"oslo": "1.2.1",
"pg": "8.19.0",
"posthog-node": "5.26.0",
"pg": "8.18.0",
"posthog-node": "5.24.15",
"qrcode.react": "4.2.0",
"react": "19.2.4",
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.71.2",
"react-hook-form": "7.71.1",
"react-icons": "5.5.0",
"recharts": "2.15.4",
"reodotdev": "1.0.0",
@@ -115,7 +115,7 @@
"sshpk": "^1.18.0",
"stripe": "20.3.1",
"swagger-ui-express": "5.0.1",
"tailwind-merge": "3.5.0",
"tailwind-merge": "3.4.0",
"topojson-client": "3.1.0",
"tw-animate-css": "1.4.0",
"use-debounce": "^10.1.0",
@@ -147,7 +147,7 @@
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.2.3",
"@types/nodemailer": "7.0.11",
"@types/nodemailer": "7.0.9",
"@types/nprogress": "0.2.3",
"@types/pg": "8.16.0",
"@types/react": "19.2.14",
+2 -2
View File
@@ -87,7 +87,7 @@ export async function validateResourceSessionToken(
if (Date.now() >= resourceSession.expiresAt) {
await db
.delete(resourceSessions)
.where(eq(resourceSessions.sessionId, sessionId));
.where(eq(resourceSessions.sessionId, resourceSessions.sessionId));
return { resourceSession: null };
} else if (
Date.now() >=
@@ -181,7 +181,7 @@ export function serializeResourceSessionCookie(
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${domain}`;
} else {
if (expiresAt === undefined) {
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${domain}`;
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=$domain}`;
}
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${domain}`;
}
-1
View File
@@ -1,5 +1,4 @@
export * from "./driver";
export * from "./logsDriver";
export * from "./safeRead";
export * from "./schema/schema";
export * from "./schema/privateSchema";
-87
View File
@@ -1,87 +0,0 @@
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { readConfigFile } from "@server/lib/readConfigFile";
import { withReplicas } from "drizzle-orm/pg-core";
import { build } from "@server/build";
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
function createLogsDb() {
// Only use separate logs database in SaaS builds
if (build !== "saas") {
return mainDb;
}
const config = readConfigFile();
// Merge configs, prioritizing private config
const logsConfig = config.postgres_logs;
// Check environment variable first
let connectionString = process.env.POSTGRES_LOGS_CONNECTION_STRING;
let replicaConnections: Array<{ connection_string: string }> = [];
if (!connectionString && logsConfig) {
connectionString = logsConfig.connection_string;
replicaConnections = logsConfig.replicas || [];
}
// If POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS is set, use it
if (process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS) {
replicaConnections =
process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS.split(",").map(
(conn) => ({
connection_string: conn.trim()
})
);
}
// If no logs database is configured, fall back to main database
if (!connectionString) {
return mainDb;
}
// Create separate connection pool for logs database
const poolConfig = logsConfig?.pool || config.postgres?.pool;
const primaryPool = new Pool({
connectionString,
max: poolConfig?.max_connections || 20,
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000
});
const replicas = [];
if (!replicaConnections.length) {
replicas.push(
DrizzlePostgres(primaryPool, {
logger: process.env.QUERY_LOGGING == "true"
})
);
} else {
for (const conn of replicaConnections) {
const replicaPool = new Pool({
connectionString: conn.connection_string,
max: poolConfig?.max_replica_connections || 20,
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
connectionTimeoutMillis:
poolConfig?.connection_timeout_ms || 5000
});
replicas.push(
DrizzlePostgres(replicaPool, {
logger: process.env.QUERY_LOGGING == "true"
})
);
}
}
return withReplicas(
DrizzlePostgres(primaryPool, {
logger: process.env.QUERY_LOGGING == "true"
}),
replicas as any
);
}
export const logsDb = createLogsDb();
export default logsDb;
export const primaryLogsDb = logsDb.$primary;
-1
View File
@@ -1,5 +1,4 @@
export * from "./driver";
export * from "./logsDriver";
export * from "./safeRead";
export * from "./schema/schema";
export * from "./schema/privateSchema";
-7
View File
@@ -1,7 +0,0 @@
import { db as mainDb } from "./driver";
// SQLite doesn't support separate databases for logs in the same way as Postgres
// Always use the main database connection for SQLite
export const logsDb = mainDb;
export default logsDb;
export const primaryLogsDb = logsDb;
-5
View File
@@ -16,11 +16,6 @@ const internalPort = config.getRawConfig().server.internal_port;
export function createInternalServer() {
const internalServer = express();
const trustProxy = config.getRawConfig().server.trust_proxy;
if (trustProxy) {
internalServer.set("trust proxy", trustProxy);
}
internalServer.use(helmet());
internalServer.use(cors());
internalServer.use(stripDuplicateSesions);
+1 -1
View File
@@ -48,5 +48,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"enterprise"
],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"]
[TierFeature.SshPam]: ["enterprise"]
};
+3 -3
View File
@@ -12,7 +12,7 @@ import {
import { FeatureId, getFeatureMeterId } from "./features";
import logger from "@server/logger";
import { build } from "@server/build";
import cache from "#dynamic/lib/cache";
import cache from "@server/lib/cache";
export function noop() {
if (build !== "saas") {
@@ -230,7 +230,7 @@ export class UsageService {
const orgIdToUse = await this.getBillingOrg(orgId);
const cacheKey = `customer_${orgIdToUse}_${featureId}`;
const cached = await cache.get<string>(cacheKey);
const cached = cache.get<string>(cacheKey);
if (cached) {
return cached;
@@ -253,7 +253,7 @@ export class UsageService {
const customerId = customer.customerId;
// Cache the result
await cache.set(cacheKey, customerId, 300); // 5 minute TTL
cache.set(cacheKey, customerId, 300); // 5 minute TTL
return customerId;
} catch (error) {
+3 -9
View File
@@ -11,7 +11,7 @@ import {
userSiteResources
} from "@server/db";
import { sites } from "@server/db";
import { eq, and, ne, inArray, or } from "drizzle-orm";
import { eq, and, ne, inArray } from "drizzle-orm";
import { Config } from "./types";
import logger from "@server/logger";
import { getNextAvailableAliasAddress } from "../ip";
@@ -142,10 +142,7 @@ export async function updateClientResources(
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(
inArray(users.username, resourceData.users),
inArray(users.email, resourceData.users)
),
inArray(users.username, resourceData.users),
eq(userOrgs.orgId, orgId)
)
);
@@ -279,10 +276,7 @@ export async function updateClientResources(
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(
inArray(users.username, resourceData.users),
inArray(users.email, resourceData.users)
),
inArray(users.username, resourceData.users),
eq(userOrgs.orgId, orgId)
)
);
+4 -18
View File
@@ -212,10 +212,7 @@ export async function updateProxyResources(
} else {
// Update existing resource
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.maintencePage
);
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage);
if (!isLicensed) {
resourceData.maintenance = undefined;
}
@@ -593,10 +590,7 @@ export async function updateProxyResources(
existingRule.action !== getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !==
getRuleValue(
rule.match.toUpperCase(),
rule.value
) ||
getRuleValue(rule.match.toUpperCase(), rule.value) ||
existingRule.priority !== intendedPriority
) {
validateRule(rule);
@@ -654,10 +648,7 @@ export async function updateProxyResources(
);
}
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.maintencePage
);
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage);
if (!isLicensed) {
resourceData.maintenance = undefined;
}
@@ -944,12 +935,7 @@ async function syncUserResources(
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.where(and(eq(users.username, username), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!user) {
+2 -2
View File
@@ -69,7 +69,7 @@ export const AuthSchema = z.object({
.refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in sso-roles"
}),
"sso-users": z.array(z.string()).optional().default([]),
"sso-users": z.array(z.email()).optional().default([]),
"whitelist-users": z.array(z.email()).optional().default([]),
"auto-login-idp": z.int().positive().optional()
});
@@ -335,7 +335,7 @@ export const ClientResourceSchema = z
.refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in roles"
}),
users: z.array(z.string()).optional().default([]),
users: z.array(z.email()).optional().default([]),
machines: z.array(z.string()).optional().default([])
})
.refine(
+4 -145
View File
@@ -1,9 +1,9 @@
import NodeCache from "node-cache";
import logger from "@server/logger";
// Create local cache with maxKeys limit to prevent memory leaks
// Create cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
export const localCache = new NodeCache({
export const cache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
@@ -11,151 +11,10 @@ export const localCache = new NodeCache({
// Log cache statistics periodically for monitoring
setInterval(() => {
const stats = localCache.getStats();
const stats = cache.getStats();
logger.debug(
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
`Cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
);
}, 300000); // Every 5 minutes
/**
* Adaptive cache that uses Redis when available in multi-node environments,
* otherwise falls back to local memory cache for single-node deployments.
*/
class AdaptiveCache {
/**
* Set a value in the cache
* @param key - Cache key
* @param value - Value to cache (will be JSON stringified for Redis)
* @param ttl - Time to live in seconds (0 = no expiration)
* @returns boolean indicating success
*/
async set(key: string, value: any, ttl?: number): Promise<boolean> {
const effectiveTtl = ttl === 0 ? undefined : ttl;
// Use local cache as fallback or primary
const success = localCache.set(key, value, effectiveTtl || 0);
if (success) {
logger.debug(`Set key in local cache: ${key}`);
}
return success;
}
/**
* Get a value from the cache
* @param key - Cache key
* @returns The cached value or undefined if not found
*/
async get<T = any>(key: string): Promise<T | undefined> {
// Use local cache as fallback or primary
const value = localCache.get<T>(key);
if (value !== undefined) {
logger.debug(`Cache hit in local cache: ${key}`);
} else {
logger.debug(`Cache miss in local cache: ${key}`);
}
return value;
}
/**
* Delete a value from the cache
* @param key - Cache key or array of keys
* @returns Number of deleted entries
*/
async del(key: string | string[]): Promise<number> {
const keys = Array.isArray(key) ? key : [key];
let deletedCount = 0;
// Use local cache as fallback or primary
for (const k of keys) {
const success = localCache.del(k);
if (success > 0) {
deletedCount++;
logger.debug(`Deleted key from local cache: ${k}`);
}
}
return deletedCount;
}
/**
* Check if a key exists in the cache
* @param key - Cache key
* @returns boolean indicating if key exists
*/
async has(key: string): Promise<boolean> {
// Use local cache as fallback or primary
return localCache.has(key);
}
/**
* Get multiple values from the cache
* @param keys - Array of cache keys
* @returns Array of values (undefined for missing keys)
*/
async mget<T = any>(keys: string[]): Promise<(T | undefined)[]> {
// Use local cache as fallback or primary
return keys.map((key) => localCache.get<T>(key));
}
/**
* Flush all keys from the cache
*/
async flushAll(): Promise<void> {
localCache.flushAll();
logger.debug("Flushed local cache");
}
/**
* Get cache statistics
* Note: Only returns local cache stats, Redis stats are not included
*/
getStats() {
return localCache.getStats();
}
/**
* Get the current cache backend being used
* @returns "redis" if Redis is available and healthy, "local" otherwise
*/
getCurrentBackend(): "redis" | "local" {
return "local";
}
/**
* Take a key from the cache and delete it
* @param key - Cache key
* @returns The value or undefined if not found
*/
async take<T = any>(key: string): Promise<T | undefined> {
const value = await this.get<T>(key);
if (value !== undefined) {
await this.del(key);
}
return value;
}
/**
* Get TTL (time to live) for a key
* @param key - Cache key
* @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist
*/
getTtl(key: string): number {
const ttl = localCache.getTtl(key);
if (ttl === undefined) {
return -1;
}
return Math.max(0, Math.floor((ttl - Date.now()) / 1000));
}
/**
* Get all keys from the cache
* Note: Only returns local cache keys, Redis keys are not included
*/
keys(): string[] {
return localCache.keys();
}
}
// Export singleton instance
export const cache = new AdaptiveCache();
export default cache;
+1 -1
View File
@@ -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.16.0";
export const APP_VERSION = "1.15.4";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);
-40
View File
@@ -189,46 +189,6 @@ export const configSchema = z
.prefault({})
})
.optional(),
postgres_logs: z
.object({
connection_string: z
.string()
.optional()
.transform(getEnvOrYaml("POSTGRES_LOGS_CONNECTION_STRING")),
replicas: z
.array(
z.object({
connection_string: z.string()
})
)
.optional(),
pool: z
.object({
max_connections: z
.number()
.positive()
.optional()
.default(20),
max_replica_connections: z
.number()
.positive()
.optional()
.default(10),
idle_timeout_ms: z
.number()
.positive()
.optional()
.default(30000),
connection_timeout_ms: z
.number()
.positive()
.optional()
.default(5000)
})
.optional()
.prefault({})
})
.optional(),
traefik: z
.object({
http_entrypoint: z.string().optional().default("web"),
-266
View File
@@ -1,266 +0,0 @@
import NodeCache from "node-cache";
import logger from "@server/logger";
import { redisManager } from "@server/private/lib/redis";
// Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
export const localCache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
// Log cache statistics periodically for monitoring
setInterval(() => {
const stats = localCache.getStats();
logger.debug(
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
);
}, 300000); // Every 5 minutes
/**
* Adaptive cache that uses Redis when available in multi-node environments,
* otherwise falls back to local memory cache for single-node deployments.
*/
class AdaptiveCache {
private useRedis(): boolean {
return redisManager.isRedisEnabled() && redisManager.getHealthStatus().isHealthy;
}
/**
* Set a value in the cache
* @param key - Cache key
* @param value - Value to cache (will be JSON stringified for Redis)
* @param ttl - Time to live in seconds (0 = no expiration)
* @returns boolean indicating success
*/
async set(key: string, value: any, ttl?: number): Promise<boolean> {
const effectiveTtl = ttl === 0 ? undefined : ttl;
if (this.useRedis()) {
try {
const serialized = JSON.stringify(value);
const success = await redisManager.set(key, serialized, effectiveTtl);
if (success) {
logger.debug(`Set key in Redis: ${key}`);
return true;
}
// Redis failed, fall through to local cache
logger.debug(`Redis set failed for key ${key}, falling back to local cache`);
} catch (error) {
logger.error(`Redis set error for key ${key}:`, error);
// Fall through to local cache
}
}
// Use local cache as fallback or primary
const success = localCache.set(key, value, effectiveTtl || 0);
if (success) {
logger.debug(`Set key in local cache: ${key}`);
}
return success;
}
/**
* Get a value from the cache
* @param key - Cache key
* @returns The cached value or undefined if not found
*/
async get<T = any>(key: string): Promise<T | undefined> {
if (this.useRedis()) {
try {
const value = await redisManager.get(key);
if (value !== null) {
logger.debug(`Cache hit in Redis: ${key}`);
return JSON.parse(value) as T;
}
logger.debug(`Cache miss in Redis: ${key}`);
return undefined;
} catch (error) {
logger.error(`Redis get error for key ${key}:`, error);
// Fall through to local cache
}
}
// Use local cache as fallback or primary
const value = localCache.get<T>(key);
if (value !== undefined) {
logger.debug(`Cache hit in local cache: ${key}`);
} else {
logger.debug(`Cache miss in local cache: ${key}`);
}
return value;
}
/**
* Delete a value from the cache
* @param key - Cache key or array of keys
* @returns Number of deleted entries
*/
async del(key: string | string[]): Promise<number> {
const keys = Array.isArray(key) ? key : [key];
let deletedCount = 0;
if (this.useRedis()) {
try {
for (const k of keys) {
const success = await redisManager.del(k);
if (success) {
deletedCount++;
logger.debug(`Deleted key from Redis: ${k}`);
}
}
if (deletedCount === keys.length) {
return deletedCount;
}
// Some Redis deletes failed, fall through to local cache
logger.debug(`Some Redis deletes failed, falling back to local cache`);
} catch (error) {
logger.error(`Redis del error for keys ${keys.join(", ")}:`, error);
// Fall through to local cache
deletedCount = 0;
}
}
// Use local cache as fallback or primary
for (const k of keys) {
const success = localCache.del(k);
if (success > 0) {
deletedCount++;
logger.debug(`Deleted key from local cache: ${k}`);
}
}
return deletedCount;
}
/**
* Check if a key exists in the cache
* @param key - Cache key
* @returns boolean indicating if key exists
*/
async has(key: string): Promise<boolean> {
if (this.useRedis()) {
try {
const value = await redisManager.get(key);
return value !== null;
} catch (error) {
logger.error(`Redis has error for key ${key}:`, error);
// Fall through to local cache
}
}
// Use local cache as fallback or primary
return localCache.has(key);
}
/**
* Get multiple values from the cache
* @param keys - Array of cache keys
* @returns Array of values (undefined for missing keys)
*/
async mget<T = any>(keys: string[]): Promise<(T | undefined)[]> {
if (this.useRedis()) {
try {
const results: (T | undefined)[] = [];
for (const key of keys) {
const value = await redisManager.get(key);
if (value !== null) {
results.push(JSON.parse(value) as T);
} else {
results.push(undefined);
}
}
return results;
} catch (error) {
logger.error(`Redis mget error:`, error);
// Fall through to local cache
}
}
// Use local cache as fallback or primary
return keys.map((key) => localCache.get<T>(key));
}
/**
* Flush all keys from the cache
*/
async flushAll(): Promise<void> {
if (this.useRedis()) {
logger.warn("Adaptive cache flushAll called - Redis flush not implemented, only local cache will be flushed");
}
localCache.flushAll();
logger.debug("Flushed local cache");
}
/**
* Get cache statistics
* Note: Only returns local cache stats, Redis stats are not included
*/
getStats() {
return localCache.getStats();
}
/**
* Get the current cache backend being used
* @returns "redis" if Redis is available and healthy, "local" otherwise
*/
getCurrentBackend(): "redis" | "local" {
return this.useRedis() ? "redis" : "local";
}
/**
* Take a key from the cache and delete it
* @param key - Cache key
* @returns The value or undefined if not found
*/
async take<T = any>(key: string): Promise<T | undefined> {
const value = await this.get<T>(key);
if (value !== undefined) {
await this.del(key);
}
return value;
}
/**
* Get TTL (time to live) for a key
* @param key - Cache key
* @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist
*/
getTtl(key: string): number {
// Note: This only works for local cache, Redis TTL is not supported
if (this.useRedis()) {
logger.warn(`getTtl called for key ${key} but Redis TTL lookup is not implemented`);
}
const ttl = localCache.getTtl(key);
if (ttl === undefined) {
return -1;
}
return Math.max(0, Math.floor((ttl - Date.now()) / 1000));
}
/**
* Get all keys from the cache
* Note: Only returns local cache keys, Redis keys are not included
*/
keys(): string[] {
if (this.useRedis()) {
logger.warn("keys() called but Redis keys are not included, only local cache keys returned");
}
return localCache.keys();
}
}
// Export singleton instance
export const cache = new AdaptiveCache();
export default cache;
+4 -3
View File
@@ -15,8 +15,9 @@ import config from "./config";
import { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decryptData } from "@server/lib/encryption";
import * as fs from "fs";
import logger from "@server/logger";
import cache from "#private/lib/cache";
import cache from "@server/lib/cache";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
@@ -54,7 +55,7 @@ export async function getValidCertificatesForDomains(
if (useCache) {
for (const domain of domains) {
const cacheKey = `cert:${domain}`;
const cachedCert = await cache.get<CertificateResult>(cacheKey);
const cachedCert = cache.get<CertificateResult>(cacheKey);
if (cachedCert) {
finalResults.push(cachedCert); // Valid cache hit
} else {
@@ -168,7 +169,7 @@ export async function getValidCertificatesForDomains(
// Add to cache for future requests, using the *requested domain* as the key
if (useCache) {
const cacheKey = `cert:${domain}`;
await cache.set(cacheKey, resultCert, 180);
cache.set(cacheKey, resultCert, 180);
}
}
}
+8 -8
View File
@@ -11,17 +11,17 @@
* This file is not licensed under the AGPLv3.
*/
import { accessAuditLog, logsDb, db, orgs } from "@server/db";
import { accessAuditLog, db, orgs } from "@server/db";
import { getCountryCodeForIp } from "@server/lib/geoip";
import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm";
import cache from "#private/lib/cache";
import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { stripPortFromHost } from "@server/lib/ip";
async function getAccessDays(orgId: string): Promise<number> {
// check cache first
const cached = await cache.get<number>(`org_${orgId}_accessDays`);
const cached = cache.get<number>(`org_${orgId}_accessDays`);
if (cached !== undefined) {
return cached;
}
@@ -39,7 +39,7 @@ async function getAccessDays(orgId: string): Promise<number> {
}
// store the result in cache
await cache.set(
cache.set(
`org_${orgId}_accessDays`,
org.settingsLogRetentionDaysAction,
300
@@ -52,7 +52,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
try {
await logsDb
await db
.delete(accessAuditLog)
.where(
and(
@@ -124,7 +124,7 @@ export async function logAccessAudit(data: {
? await getCountryCodeFromIp(data.requestIp)
: undefined;
await logsDb.insert(accessAuditLog).values({
await db.insert(accessAuditLog).values({
timestamp: timestamp,
orgId: data.orgId,
actorType,
@@ -146,14 +146,14 @@ export async function logAccessAudit(data: {
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
const geoIpCacheKey = `geoip_access:${ip}`;
let cachedCountryCode: string | undefined = await cache.get(geoIpCacheKey);
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
if (!cachedCountryCode) {
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
// Only cache successful lookups to avoid filling cache with undefined values
if (cachedCountryCode) {
// Cache for longer since IP geolocation doesn't change frequently
await cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
}
}
@@ -1,3 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import * as crypto from "crypto";
/**
+6 -6
View File
@@ -12,18 +12,18 @@
*/
import { ActionsEnum } from "@server/auth/actions";
import { actionAuditLog, logsDb, db, orgs } from "@server/db";
import { actionAuditLog, db, orgs } from "@server/db";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { and, eq, lt } from "drizzle-orm";
import cache from "#private/lib/cache";
import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
async function getActionDays(orgId: string): Promise<number> {
// check cache first
const cached = await cache.get<number>(`org_${orgId}_actionDays`);
const cached = cache.get<number>(`org_${orgId}_actionDays`);
if (cached !== undefined) {
return cached;
}
@@ -41,7 +41,7 @@ async function getActionDays(orgId: string): Promise<number> {
}
// store the result in cache
await cache.set(
cache.set(
`org_${orgId}_actionDays`,
org.settingsLogRetentionDaysAction,
300
@@ -54,7 +54,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
try {
await logsDb
await db
.delete(actionAuditLog)
.where(
and(
@@ -123,7 +123,7 @@ export function logActionAudit(action: ActionsEnum) {
metadata = JSON.stringify(req.params);
}
await logsDb.insert(actionAuditLog).values({
await db.insert(actionAuditLog).values({
timestamp,
orgId,
actorType,
@@ -11,11 +11,11 @@
* This file is not licensed under the AGPLv3.
*/
import { accessAuditLog, logsDb, resources, db, primaryDb } from "@server/db";
import { accessAuditLog, db, resources } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
import { eq, gt, lt, and, count, desc } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
@@ -115,13 +115,15 @@ function getWhere(data: Q) {
}
export function queryAccess(data: Q) {
return logsDb
return db
.select({
orgId: accessAuditLog.orgId,
action: accessAuditLog.action,
actorType: accessAuditLog.actorType,
actorId: accessAuditLog.actorId,
resourceId: accessAuditLog.resourceId,
resourceName: resources.name,
resourceNiceId: resources.niceId,
ip: accessAuditLog.ip,
location: accessAuditLog.location,
userAgent: accessAuditLog.userAgent,
@@ -131,46 +133,16 @@ export function queryAccess(data: Q) {
actor: accessAuditLog.actor
})
.from(accessAuditLog)
.leftJoin(
resources,
eq(accessAuditLog.resourceId, resources.resourceId)
)
.where(getWhere(data))
.orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id));
}
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryAccess>>) {
// If logs database is the same as main database, we can do a join
// Otherwise, we need to fetch resource details separately
const resourceIds = logs
.map(log => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
}
// Fetch resource details from main database
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
// Create a map for quick lookup
const resourceMap = new Map(
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
);
// Enrich logs with resource details
return logs.map(log => ({
...log,
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
}));
}
export function countAccessQuery(data: Q) {
const countQuery = logsDb
const countQuery = db
.select({ count: count() })
.from(accessAuditLog)
.where(getWhere(data));
@@ -189,7 +161,7 @@ async function queryUniqueFilterAttributes(
);
// Get unique actors
const uniqueActors = await logsDb
const uniqueActors = await db
.selectDistinct({
actor: accessAuditLog.actor
})
@@ -197,7 +169,7 @@ async function queryUniqueFilterAttributes(
.where(baseConditions);
// Get unique locations
const uniqueLocations = await logsDb
const uniqueLocations = await db
.selectDistinct({
locations: accessAuditLog.location
})
@@ -205,40 +177,25 @@ async function queryUniqueFilterAttributes(
.where(baseConditions);
// Get unique resources with names
const uniqueResources = await logsDb
const uniqueResources = await db
.selectDistinct({
id: accessAuditLog.resourceId
id: accessAuditLog.resourceId,
name: resources.name
})
.from(accessAuditLog)
.leftJoin(
resources,
eq(accessAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions);
// Fetch resource names from main database for the unique resource IDs
const resourceIds = uniqueResources
.map(row => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = resourceDetails.map(r => ({
id: r.resourceId,
name: r.name
}));
}
return {
actors: uniqueActors
.map((row) => row.actor)
.filter((actor): actor is string => actor !== null),
resources: resourcesWithNames,
resources: uniqueResources.filter(
(row): row is { id: number; name: string | null } => row.id !== null
),
locations: uniqueLocations
.map((row) => row.locations)
.filter((location): location is string => location !== null)
@@ -286,10 +243,7 @@ export async function queryAccessAuditLogs(
const baseQuery = queryAccess(data);
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
// Enrich with resource details (handles cross-database scenario)
const log = await enrichWithResourceDetails(logsRaw);
const log = await baseQuery.limit(data.limit).offset(data.offset);
const totalCountResult = await countAccessQuery(data);
const totalCount = totalCountResult[0].count;
@@ -11,7 +11,7 @@
* This file is not licensed under the AGPLv3.
*/
import { actionAuditLog, logsDb } from "@server/db";
import { actionAuditLog, db } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
@@ -97,7 +97,7 @@ function getWhere(data: Q) {
}
export function queryAction(data: Q) {
return logsDb
return db
.select({
orgId: actionAuditLog.orgId,
action: actionAuditLog.action,
@@ -113,7 +113,7 @@ export function queryAction(data: Q) {
}
export function countActionQuery(data: Q) {
const countQuery = logsDb
const countQuery = db
.select({ count: count() })
.from(actionAuditLog)
.where(getWhere(data));
@@ -132,14 +132,14 @@ async function queryUniqueFilterAttributes(
);
// Get unique actors
const uniqueActors = await logsDb
const uniqueActors = await db
.selectDistinct({
actor: actionAuditLog.actor
})
.from(actionAuditLog)
.where(baseConditions);
const uniqueActions = await logsDb
const uniqueActions = await db
.selectDistinct({
action: actionAuditLog.action
})
+2 -2
View File
@@ -480,9 +480,9 @@ authenticated.get(
authenticated.post(
"/re-key/:clientId/regenerate-client-secret",
verifyClientAccess, // this is first to set the org id
verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials),
verifyClientAccess, // this is first to set the org id
verifyLimits,
verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateClientSecret
@@ -490,9 +490,9 @@ authenticated.post(
authenticated.post(
"/re-key/:siteId/regenerate-site-secret",
verifySiteAccess, // this is first to set the org id
verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials),
verifySiteAccess, // this is first to set the org id
verifyLimits,
verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateSiteSecret
+2 -24
View File
@@ -32,7 +32,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq, or, and } from "drizzle-orm";
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
import config from "@server/lib/config";
import { sendToClient } from "#private/routers/ws";
@@ -176,7 +176,7 @@ export async function signSshKey(
} else if (req.user?.username) {
usernameToUse = req.user.username;
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-");
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "");
if (!usernameToUse) {
return next(
createHttpError(
@@ -194,9 +194,6 @@ export async function signSshKey(
);
}
// prefix with p-
usernameToUse = `p-${usernameToUse}`;
// check if we have a existing user in this org with the same
const [existingUserWithSameName] = await db
.select()
@@ -242,16 +239,6 @@ export async function signSshKey(
);
}
}
await db
.update(userOrgs)
.set({ pamUsername: usernameToUse })
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, userId)
)
);
} else {
usernameToUse = userOrg.pamUsername;
}
@@ -323,15 +310,6 @@ export async function signSshKey(
);
}
if (resource.mode == "cidr") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"SSHing is not supported for CIDR resources"
)
);
}
// Check if the user has access to the resource
const hasAccess = await canUserAccessSiteResource({
userId: userId,
@@ -1,4 +1,4 @@
import { logsDb, requestAuditLog, driver, primaryLogsDb } from "@server/db";
import { db, requestAuditLog, driver, primaryDb } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
@@ -74,12 +74,12 @@ async function query(query: Q) {
);
}
const [all] = await primaryLogsDb
const [all] = await primaryDb
.select({ total: count() })
.from(requestAuditLog)
.where(baseConditions);
const [blocked] = await primaryLogsDb
const [blocked] = await primaryDb
.select({ total: count() })
.from(requestAuditLog)
.where(and(baseConditions, eq(requestAuditLog.action, false)));
@@ -90,7 +90,7 @@ async function query(query: Q) {
const DISTINCT_LIMIT = 500;
const requestsPerCountry = await primaryLogsDb
const requestsPerCountry = await primaryDb
.selectDistinct({
code: requestAuditLog.location,
count: totalQ
@@ -118,7 +118,7 @@ async function query(query: Q) {
const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
const requestsPerDay = await primaryLogsDb
const requestsPerDay = await primaryDb
.select({
day: groupByDayFunction.as("day"),
allowedCount:
@@ -1,8 +1,8 @@
import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db";
import { db, primaryDb, requestAuditLog, resources } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
import { eq, gt, lt, and, count, desc } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
@@ -107,7 +107,7 @@ function getWhere(data: Q) {
}
export function queryRequest(data: Q) {
return primaryLogsDb
return primaryDb
.select({
id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp,
@@ -129,49 +129,21 @@ export function queryRequest(data: Q) {
host: requestAuditLog.host,
path: requestAuditLog.path,
method: requestAuditLog.method,
tls: requestAuditLog.tls
tls: requestAuditLog.tls,
resourceName: resources.name,
resourceNiceId: resources.niceId
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
) // TODO: Is this efficient?
.where(getWhere(data))
.orderBy(desc(requestAuditLog.timestamp));
}
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
// If logs database is the same as main database, we can do a join
// Otherwise, we need to fetch resource details separately
const resourceIds = logs
.map(log => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
}
// Fetch resource details from main database
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
// Create a map for quick lookup
const resourceMap = new Map(
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
);
// Enrich logs with resource details
return logs.map(log => ({
...log,
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
}));
}
export function countRequestQuery(data: Q) {
const countQuery = primaryLogsDb
const countQuery = primaryDb
.select({ count: count() })
.from(requestAuditLog)
.where(getWhere(data));
@@ -213,31 +185,36 @@ async function queryUniqueFilterAttributes(
uniquePaths,
uniqueResources
] = await Promise.all([
primaryLogsDb
primaryDb
.selectDistinct({ actor: requestAuditLog.actor })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
primaryDb
.selectDistinct({ locations: requestAuditLog.location })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
primaryDb
.selectDistinct({ hosts: requestAuditLog.host })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
primaryDb
.selectDistinct({ paths: requestAuditLog.path })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
primaryDb
.selectDistinct({
id: requestAuditLog.resourceId
id: requestAuditLog.resourceId,
name: resources.name
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1)
]);
@@ -254,33 +231,13 @@ async function queryUniqueFilterAttributes(
// throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range.");
// }
// Fetch resource names from main database for the unique resource IDs
const resourceIds = uniqueResources
.map(row => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = resourceDetails.map(r => ({
id: r.resourceId,
name: r.name
}));
}
return {
actors: uniqueActors
.map((row) => row.actor)
.filter((actor): actor is string => actor !== null),
resources: resourcesWithNames,
resources: uniqueResources.filter(
(row): row is { id: number; name: string | null } => row.id !== null
),
locations: uniqueLocations
.map((row) => row.locations)
.filter((location): location is string => location !== null),
@@ -323,10 +280,7 @@ export async function queryRequestAuditLogs(
const baseQuery = queryRequest(data);
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
// Enrich with resource details (handles cross-database scenario)
const log = await enrichWithResourceDetails(logsRaw);
const log = await baseQuery.limit(data.limit).offset(data.offset);
const totalCountResult = await countRequestQuery(data);
const totalCount = totalCountResult[0].count;
+6 -6
View File
@@ -1,7 +1,7 @@
import { logsDb, primaryLogsDb, db, orgs, requestAuditLog } from "@server/db";
import { db, orgs, requestAuditLog } from "@server/db";
import logger from "@server/logger";
import { and, eq, lt, sql } from "drizzle-orm";
import cache from "#dynamic/lib/cache";
import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { stripPortFromHost } from "@server/lib/ip";
@@ -69,7 +69,7 @@ async function flushAuditLogs() {
try {
// Use a transaction to ensure all inserts succeed or fail together
// This prevents index corruption from partial writes
await logsDb.transaction(async (tx) => {
await db.transaction(async (tx) => {
// Batch insert logs in groups of 25 to avoid overwhelming the database
const BATCH_DB_SIZE = 25;
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
@@ -130,7 +130,7 @@ export async function shutdownAuditLogger() {
async function getRetentionDays(orgId: string): Promise<number> {
// check cache first
const cached = await cache.get<number>(`org_${orgId}_retentionDays`);
const cached = cache.get<number>(`org_${orgId}_retentionDays`);
if (cached !== undefined) {
return cached;
}
@@ -149,7 +149,7 @@ async function getRetentionDays(orgId: string): Promise<number> {
}
// store the result in cache
await cache.set(
cache.set(
`org_${orgId}_retentionDays`,
org.settingsLogRetentionDaysRequest,
300
@@ -162,7 +162,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
try {
await logsDb
await db
.delete(requestAuditLog)
.where(
and(
+15 -15
View File
@@ -37,7 +37,7 @@ import {
enforceResourceSessionLength
} from "#dynamic/lib/checkOrgAccessPolicy";
import { logRequestAudit } from "./logRequestAudit";
import { localCache } from "#dynamic/lib/cache";
import cache from "@server/lib/cache";
import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -137,7 +137,7 @@ export async function verifyResourceSession(
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org;
}
| undefined = localCache.get(resourceCacheKey);
| undefined = cache.get(resourceCacheKey);
if (!resourceData) {
const result = await getResourceByDomain(cleanHost);
@@ -161,7 +161,7 @@ export async function verifyResourceSession(
}
resourceData = result;
localCache.set(resourceCacheKey, resourceData, 5);
cache.set(resourceCacheKey, resourceData, 5);
}
const {
@@ -405,7 +405,7 @@ export async function verifyResourceSession(
// check for HTTP Basic Auth header
const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`;
if (headerAuth && clientHeaderAuth) {
if (localCache.get(clientHeaderAuthKey)) {
if (cache.get(clientHeaderAuthKey)) {
logger.debug(
"Resource allowed because header auth is valid (cached)"
);
@@ -428,7 +428,7 @@ export async function verifyResourceSession(
headerAuth.headerAuthHash
)
) {
localCache.set(clientHeaderAuthKey, clientHeaderAuth, 5);
cache.set(clientHeaderAuthKey, clientHeaderAuth, 5);
logger.debug("Resource allowed because header auth is valid");
logRequestAudit(
@@ -520,7 +520,7 @@ export async function verifyResourceSession(
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = localCache.get(sessionCacheKey);
let resourceSession: any = cache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
@@ -529,7 +529,7 @@ export async function verifyResourceSession(
);
resourceSession = result?.resourceSession;
localCache.set(sessionCacheKey, resourceSession, 5);
cache.set(sessionCacheKey, resourceSession, 5);
}
if (resourceSession?.isRequestToken) {
@@ -662,7 +662,7 @@ export async function verifyResourceSession(
}:${resource.resourceId}`;
let allowedUserData: BasicUserData | null | undefined =
localCache.get(userAccessCacheKey);
cache.get(userAccessCacheKey);
if (allowedUserData === undefined) {
allowedUserData = await isUserAllowedToAccessResource(
@@ -671,7 +671,7 @@ export async function verifyResourceSession(
resourceData.org
);
localCache.set(userAccessCacheKey, allowedUserData, 5);
cache.set(userAccessCacheKey, allowedUserData, 5);
}
if (
@@ -974,11 +974,11 @@ async function checkRules(
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`;
let rules: ResourceRule[] | undefined = localCache.get(ruleCacheKey);
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
if (!rules) {
rules = await getResourceRules(resourceId);
localCache.set(ruleCacheKey, rules, 5);
cache.set(ruleCacheKey, rules, 5);
}
if (rules.length === 0) {
@@ -1208,13 +1208,13 @@ async function isIpInAsn(
async function getAsnFromIp(ip: string): Promise<number | undefined> {
const asnCacheKey = `asn:${ip}`;
let cachedAsn: number | undefined = localCache.get(asnCacheKey);
let cachedAsn: number | undefined = cache.get(asnCacheKey);
if (!cachedAsn) {
cachedAsn = await getAsnForIp(ip); // do it locally
// Cache for longer since IP ASN doesn't change frequently
if (cachedAsn) {
localCache.set(asnCacheKey, cachedAsn, 300); // 5 minutes
cache.set(asnCacheKey, cachedAsn, 300); // 5 minutes
}
}
@@ -1224,14 +1224,14 @@ async function getAsnFromIp(ip: string): Promise<number | undefined> {
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
const geoIpCacheKey = `geoip:${ip}`;
let cachedCountryCode: string | undefined = localCache.get(geoIpCacheKey);
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
if (!cachedCountryCode) {
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
// Only cache successful lookups to avoid filling cache with undefined values
if (cachedCountryCode) {
// Cache for longer since IP geolocation doesn't change frequently
localCache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
}
}
+3 -3
View File
@@ -119,12 +119,12 @@ const listClientsSchema = z.object({
}),
query: z.string().optional(),
sort_by: z
.enum(["name", "megabytesIn", "megabytesOut"])
.enum(["megabytesIn", "megabytesOut"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name", "megabytesIn", "megabytesOut"],
enum: ["megabytesIn", "megabytesOut"],
description: "Field to sort by"
}),
order: z
@@ -363,7 +363,7 @@ export async function listClients(
const countQuery = db.$count(baseQuery.as("filtered_clients"));
const listMachinesQuery = baseQuery
.limit(pageSize)
.limit(page)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
-7
View File
@@ -689,13 +689,6 @@ authenticated.get(
user.getOrgUser
);
authenticated.get(
"/org/:orgId/user-by-username",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.getOrgUser),
user.getOrgUserByUsername
);
authenticated.post(
"/user/:userId/2fa",
verifyApiKeyIsRoot,
+4 -4
View File
@@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws";
import logger from "@server/logger";
import { Newt } from "@server/db";
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
import cache from "#dynamic/lib/cache";
import cache from "@server/lib/cache";
export const handleDockerStatusMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
@@ -24,8 +24,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => {
if (available) {
logger.info(`Newt ${newt.newtId} has Docker socket access`);
await cache.set(`${newt.newtId}:socketPath`, socketPath, 0);
await cache.set(`${newt.newtId}:isAvailable`, available, 0);
cache.set(`${newt.newtId}:socketPath`, socketPath, 0);
cache.set(`${newt.newtId}:isAvailable`, available, 0);
} else {
logger.warn(`Newt ${newt.newtId} does not have Docker socket access`);
}
@@ -54,7 +54,7 @@ export const handleDockerContainersMessage: MessageHandler = async (
);
if (containers && containers.length > 0) {
await cache.set(`${newt.newtId}:dockerContainers`, containers, 0);
cache.set(`${newt.newtId}:dockerContainers`, containers, 0);
} else {
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
}
+13 -43
View File
@@ -1,7 +1,4 @@
import {
generateSessionToken,
validateSessionToken
} from "@server/auth/sessions/app";
import { generateSessionToken } from "@server/auth/sessions/app";
import {
clients,
db,
@@ -29,9 +26,8 @@ import { APP_VERSION } from "@server/lib/consts";
export const olmGetTokenBodySchema = z.object({
olmId: z.string(),
secret: z.string().optional(),
userToken: z.string().optional(),
token: z.string().optional(), // this is the olm token
secret: z.string(),
token: z.string().optional(),
orgId: z.string().optional()
});
@@ -53,7 +49,7 @@ export async function getOlmToken(
);
}
const { olmId, secret, token, orgId, userToken } = parsedBody.data;
const { olmId, secret, token, orgId } = parsedBody.data;
try {
if (token) {
@@ -88,45 +84,19 @@ export async function getOlmToken(
);
}
if (userToken) {
const { session: userSession, user } =
await validateSessionToken(userToken);
if (!userSession || !user) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid user token")
);
}
if (user.userId !== existingOlm.userId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User token does not match olm"
)
);
}
} else if (secret) {
// this is for backward compatibility, we want to move towards userToken but some old clients may still be using secret so we will support both for now
const validSecret = await verifyPassword(
secret,
existingOlm.secretHash
);
const validSecret = await verifyPassword(
secret,
existingOlm.secretHash
);
if (!validSecret) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Olm id or secret is incorrect. Olm: ID ${olmId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
if (!validSecret) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Olm id or secret is incorrect. Olm: ID ${olmId}. IP: ${req.ip}.`
);
}
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Either secret or userToken is required"
)
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { doCidrsOverlap } from "@server/lib/ip";
import { generateCA } from "@server/lib/sshCA";
import { generateCA } from "@server/private/lib/sshCA";
import { encrypt } from "@server/lib/crypto";
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
+4 -4
View File
@@ -10,7 +10,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { cache } from "#dynamic/lib/cache";
import { cache } from "@server/lib/cache";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { getOrgTierData } from "#dynamic/lib/billing";
@@ -194,9 +194,9 @@ export async function updateOrg(
}
// invalidate the cache for all of the orgs retention days
await cache.del(`org_${orgId}_retentionDays`);
await cache.del(`org_${orgId}_actionDays`);
await cache.del(`org_${orgId}_accessDays`);
cache.del(`org_${orgId}_retentionDays`);
cache.del(`org_${orgId}_actionDays`);
cache.del(`org_${orgId}_accessDays`);
return response(res, {
data: updatedOrg[0],
+3 -38
View File
@@ -19,7 +19,6 @@ import {
and,
asc,
count,
desc,
eq,
inArray,
isNull,
@@ -64,26 +63,6 @@ const listResourcesSchema = z.object({
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
enabled: z
.enum(["true", "false"])
.transform((v) => v === "true")
@@ -250,16 +229,8 @@ export async function listResources(
)
);
}
const {
page,
pageSize,
authState,
enabled,
query,
healthStatus,
sort_by,
order
} = parsedQuery.data;
const { page, pageSize, authState, enabled, query, healthStatus } =
parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@@ -424,13 +395,7 @@ export async function listResources(
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(resources[sort_by])
: desc(resources[sort_by])
: asc(resources.resourceId)
),
.orderBy(asc(resources.resourceId)),
countQuery
]);
+6 -6
View File
@@ -8,7 +8,7 @@ import {
sites,
userSites
} from "@server/db";
import cache from "#dynamic/lib/cache";
import cache from "@server/lib/cache";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -23,7 +23,7 @@ import { fromError } from "zod-validation-error";
async function getLatestNewtVersion(): Promise<string | null> {
try {
const cachedVersion = await cache.get<string>("latestNewtVersion");
const cachedVersion = cache.get<string>("latestNewtVersion");
if (cachedVersion) {
return cachedVersion;
}
@@ -55,7 +55,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
await cache.set("latestNewtVersion", latestVersion);
cache.set("latestNewtVersion", latestVersion);
return latestVersion;
} catch (error: any) {
@@ -108,12 +108,12 @@ const listSitesSchema = z.object({
}),
query: z.string().optional(),
sort_by: z
.enum(["name", "megabytesIn", "megabytesOut"])
.enum(["megabytesIn", "megabytesOut"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name", "megabytesIn", "megabytesOut"],
enum: ["megabytesIn", "megabytesOut"],
description: "Field to sort by"
}),
order: z
@@ -278,7 +278,7 @@ export async function listSites(
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
querySitesBase().where(and(...conditions)).as("filtered_sites")
querySitesBase().where(and(...conditions))
);
const siteListQuery = baseQuery
+6 -8
View File
@@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error";
import stoi from "@server/lib/stoi";
import { sendToClient } from "#dynamic/routers/ws";
import { fetchContainers, dockerSocket } from "../newt/dockerSocket";
import cache from "#dynamic/lib/cache";
import cache from "@server/lib/cache";
export interface ContainerNetwork {
networkId: string;
@@ -150,7 +150,7 @@ async function triggerFetch(siteId: number) {
// clear the cache for this Newt ID so that the site has to keep asking for the containers
// this is to ensure that the site always gets the latest data
await cache.del(`${newt.newtId}:dockerContainers`);
cache.del(`${newt.newtId}:dockerContainers`);
return { siteId, newtId: newt.newtId };
}
@@ -158,7 +158,7 @@ async function triggerFetch(siteId: number) {
async function queryContainers(siteId: number) {
const { newt } = await getSiteAndNewt(siteId);
const result = await cache.get<Container[]>(`${newt.newtId}:dockerContainers`);
const result = cache.get(`${newt.newtId}:dockerContainers`) as Container[];
if (!result) {
throw createHttpError(
HttpCode.TOO_EARLY,
@@ -173,7 +173,7 @@ async function isDockerAvailable(siteId: number): Promise<boolean> {
const { newt } = await getSiteAndNewt(siteId);
const key = `${newt.newtId}:isAvailable`;
const isAvailable = await cache.get(key);
const isAvailable = cache.get(key);
return !!isAvailable;
}
@@ -186,11 +186,9 @@ async function getDockerStatus(
const keys = ["isAvailable", "socketPath"];
const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`);
const values = await cache.mget<boolean | string>(mappedKeys);
const result = {
isAvailable: values[0] as boolean,
socketPath: values[1] as string | undefined
isAvailable: cache.get(mappedKeys[0]) as boolean,
socketPath: cache.get(mappedKeys[1]) as string | undefined
};
return result;
@@ -4,7 +4,7 @@ import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import { and, asc, eq, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -48,26 +48,6 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
type: "string",
enum: ["host", "cidr"],
description: "Filter site resources by mode"
}),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["name"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
})
});
@@ -151,8 +131,7 @@ export async function listAllSiteResourcesByOrg(
}
const { orgId } = parsedParams.data;
const { page, pageSize, query, mode, sort_by, order } =
parsedQuery.data;
const { page, pageSize, query, mode } = parsedQuery.data;
const conditions = [and(eq(siteResources.orgId, orgId))];
if (query) {
@@ -193,20 +172,14 @@ export async function listAllSiteResourcesByOrg(
const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count(
querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
querySiteResourcesBase().where(and(...conditions))
);
const [siteResourcesList, totalCount] = await Promise.all([
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(siteResources[sort_by])
: desc(siteResources[sort_by])
: asc(siteResources.siteResourceId)
),
.orderBy(asc(siteResources.siteResourceId)),
countQuery
]);
@@ -5,7 +5,7 @@ import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { and, asc, desc, eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -27,16 +27,7 @@ const listSiteResourcesQuerySchema = z.object({
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative()),
sort_by: z
.enum(["name"])
.optional()
.catch(undefined),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.pipe(z.int().nonnegative())
});
export type ListSiteResourcesResponse = {
@@ -84,7 +75,7 @@ export async function listSiteResources(
}
const { siteId, orgId } = parsedParams.data;
const { limit, offset, sort_by, order } = parsedQuery.data;
const { limit, offset } = parsedQuery.data;
// Verify the site exists and belongs to the org
const site = await db
@@ -107,13 +98,6 @@ export async function listSiteResources(
eq(siteResources.orgId, orgId)
)
)
.orderBy(
sort_by
? order === "asc"
? asc(siteResources[sort_by])
: desc(siteResources[sort_by])
: asc(siteResources.siteResourceId)
)
.limit(limit)
.offset(offset);
+1 -1
View File
@@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { OpenAPITags, registry } from "@server/openApi";
export async function queryUser(orgId: string, userId: string) {
async function queryUser(orgId: string, userId: string) {
const [user] = await db
.select({
orgId: userOrgs.orgId,
-136
View File
@@ -1,136 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { queryUser, type GetOrgUserResponse } from "./getOrgUser";
const getOrgUserByUsernameParamsSchema = z.strictObject({
orgId: z.string()
});
const getOrgUserByUsernameQuerySchema = z.strictObject({
username: z.string().min(1, "username is required"),
idpId: z
.string()
.optional()
.transform((v) =>
v === undefined || v === "" ? undefined : parseInt(v, 10)
)
.refine(
(v) =>
v === undefined || (Number.isInteger(v) && (v as number) > 0),
{ message: "idpId must be a positive integer" }
)
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/user-by-username",
description:
"Get a user in an organization by username. When idpId is not passed, only internal users are searched (username is globally unique for them). For external (OIDC) users, pass idpId to search by username within that identity provider.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
params: getOrgUserByUsernameParamsSchema,
query: getOrgUserByUsernameQuerySchema
},
responses: {}
});
export async function getOrgUserByUsername(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getOrgUserByUsernameParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedQuery = getOrgUserByUsernameQuerySchema.safeParse(
req.query
);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const { username, idpId } = parsedQuery.data;
const conditions = [
eq(userOrgs.orgId, orgId),
eq(users.username, username)
];
if (idpId !== undefined) {
conditions.push(eq(users.idpId, idpId));
} else {
conditions.push(eq(users.type, "internal"));
}
const candidates = await db
.select({ userId: users.userId })
.from(userOrgs)
.innerJoin(users, eq(userOrgs.userId, users.userId))
.where(and(...conditions));
if (candidates.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`User with username '${username}' not found in organization`
)
);
}
if (candidates.length > 1) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Multiple users with this username (external users from different identity providers). Specify idpId (identity provider ID) to disambiguate. When not specified, this searches for internal users only."
)
);
}
const user = await queryUser(orgId, candidates[0].userId);
if (!user) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`User with username '${username}' not found in organization`
)
);
}
return response<GetOrgUserResponse>(res, {
data: user,
success: true,
error: false,
message: "User retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-1
View File
@@ -5,7 +5,6 @@ export * from "./addUserRole";
export * from "./inviteUser";
export * from "./acceptInvite";
export * from "./getOrgUser";
export * from "./getOrgUserByUsername";
export * from "./adminListUsers";
export * from "./adminRemoveUser";
export * from "./adminGetUser";
+3 -3
View File
@@ -19,7 +19,7 @@ import { UserType } from "@server/types/UserTypes";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build";
import cache from "#dynamic/lib/cache";
import cache from "@server/lib/cache";
const inviteUserParamsSchema = z.strictObject({
orgId: z.string()
@@ -191,7 +191,7 @@ export async function inviteUser(
}
if (existingInvite.length) {
const attempts = (await cache.get<number>(email)) || 0;
const attempts = cache.get<number>(email) || 0;
if (attempts >= 3) {
return next(
createHttpError(
@@ -201,7 +201,7 @@ export async function inviteUser(
);
}
await cache.set(email, attempts + 1);
cache.set(email, attempts + 1);
const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId
const token = generateRandomString(
+2 -4
View File
@@ -5,7 +5,6 @@ import semver from "semver";
import { versionMigrations } from "../db/pg";
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import path from "path";
import { build } from "@server/build";
import m1 from "./scriptsPg/1.6.0";
import m2 from "./scriptsPg/1.7.0";
import m3 from "./scriptsPg/1.8.0";
@@ -20,7 +19,7 @@ import m11 from "./scriptsPg/1.14.0";
import m12 from "./scriptsPg/1.15.0";
import m13 from "./scriptsPg/1.15.3";
import m14 from "./scriptsPg/1.15.4";
import m15 from "./scriptsPg/1.16.0";
import { build } from "@server/build";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -40,8 +39,7 @@ const migrations = [
{ version: "1.14.0", run: m11 },
{ version: "1.15.0", run: m12 },
{ version: "1.15.3", run: m13 },
{ version: "1.15.4", run: m14 },
{ version: "1.16.0", run: m15 }
{ version: "1.15.4", run: m14 }
// Add new migrations here as they are created
] as {
version: string;
+2 -4
View File
@@ -7,7 +7,6 @@ import { versionMigrations } from "../db/sqlite";
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
import { SqliteError } from "better-sqlite3";
import fs from "fs";
import { build } from "@server/build";
import m1 from "./scriptsSqlite/1.0.0-beta1";
import m2 from "./scriptsSqlite/1.0.0-beta2";
import m3 from "./scriptsSqlite/1.0.0-beta3";
@@ -38,7 +37,7 @@ import m32 from "./scriptsSqlite/1.14.0";
import m33 from "./scriptsSqlite/1.15.0";
import m34 from "./scriptsSqlite/1.15.3";
import m35 from "./scriptsSqlite/1.15.4";
import m36 from "./scriptsSqlite/1.16.0";
import { build } from "@server/build";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -74,8 +73,7 @@ const migrations = [
{ version: "1.14.0", run: m32 },
{ version: "1.15.0", run: m33 },
{ version: "1.15.3", run: m34 },
{ version: "1.15.4", run: m35 },
{ version: "1.16.0", run: m36 }
{ version: "1.15.4", run: m35 }
// Add new migrations here as they are created
] as const;
-179
View File
@@ -1,179 +0,0 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { encrypt } from "@server/lib/crypto";
import { generateCA } from "@server/lib/sshCA";
import fs from "fs";
import yaml from "js-yaml";
const version = "1.16.0";
function getServerSecret(): string {
const envSecret = process.env.SERVER_SECRET;
const configPath = fs.existsSync(configFilePath1)
? configFilePath1
: fs.existsSync(configFilePath2)
? configFilePath2
: null;
// If no config file but an env secret is set, use the env secret directly
if (!configPath) {
if (envSecret && envSecret.length > 0) {
return envSecret;
}
throw new Error(
"Cannot generate org CA keys: no config file found and SERVER_SECRET env var is not set. " +
"Expected config.yml or config.yaml in the config directory, or set SERVER_SECRET."
);
}
const configContent = fs.readFileSync(configPath, "utf8");
const config = yaml.load(configContent) as {
server?: { secret?: string };
};
let secret = config?.server?.secret;
if (!secret || secret.length === 0) {
// Fall back to SERVER_SECRET env var if config does not contain server.secret
if (envSecret && envSecret.length > 0) {
secret = envSecret;
}
}
if (!secret || secret.length === 0) {
throw new Error(
"Cannot generate org CA keys: no server.secret in config and SERVER_SECRET env var is not set. " +
"Set server.secret in config.yml/config.yaml or set SERVER_SECRET."
);
}
return secret;
}
export default async function migration() {
console.log(`Running setup script ${version}...`);
// Ensure server secret exists before running migration (required for org CA key generation)
getServerSecret();
try {
await db.execute(sql`BEGIN`);
// Schema changes
await db.execute(sql`
CREATE TABLE "roundTripMessageTracker" (
"messageId" serial PRIMARY KEY NOT NULL,
"clientId" varchar,
"messageType" varchar,
"sentAt" bigint NOT NULL,
"receivedAt" bigint,
"error" text,
"complete" boolean DEFAULT false NOT NULL
);
`);
await db.execute(
sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPrivateKey" text;`
);
await db.execute(
sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPublicKey" text;`
);
await db.execute(
sql`ALTER TABLE "orgs" ADD COLUMN "isBillingOrg" boolean;`
);
await db.execute(
sql`ALTER TABLE "orgs" ADD COLUMN "billingOrgId" varchar;`
);
await db.execute(
sql`ALTER TABLE "roles" ADD COLUMN "sshSudoMode" varchar(32) DEFAULT 'none';`
);
await db.execute(
sql`ALTER TABLE "roles" ADD COLUMN "sshSudoCommands" text DEFAULT '[]';`
);
await db.execute(
sql`ALTER TABLE "roles" ADD COLUMN "sshCreateHomeDir" boolean DEFAULT true;`
);
await db.execute(
sql`ALTER TABLE "roles" ADD COLUMN "sshUnixGroups" text DEFAULT '[]';`
);
await db.execute(
sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonPort" integer DEFAULT 22123;`
);
await db.execute(
sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonMode" varchar(32) DEFAULT 'site';`
);
await db.execute(
sql`ALTER TABLE "userOrgs" ADD COLUMN "pamUsername" varchar;`
);
// Set all admin role sudo to "full"; other roles keep default "none"
await db.execute(
sql`UPDATE "roles" SET "sshSudoMode" = 'full' WHERE "isAdmin" = true;`
);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
// Generate and store encrypted SSH CA keys for all orgs
try {
const secret = getServerSecret();
const orgQuery = await db.execute(sql`SELECT "orgId" FROM "orgs"`);
const orgRows = orgQuery.rows as { orgId: string }[];
const failedOrgIds: string[] = [];
for (const row of orgRows) {
try {
const ca = generateCA(`pangolin-ssh-ca-${row.orgId}`);
const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret);
await db.execute(sql`
UPDATE "orgs"
SET "sshCaPrivateKey" = ${encryptedPrivateKey},
"sshCaPublicKey" = ${ca.publicKeyOpenSSH}
WHERE "orgId" = ${row.orgId};
`);
} catch (err) {
failedOrgIds.push(row.orgId);
console.error(
`Error: No CA was generated for organization "${row.orgId}".`,
err instanceof Error ? err.message : err
);
}
}
if (orgRows.length > 0) {
const succeeded = orgRows.length - failedOrgIds.length;
console.log(
`Generated and stored SSH CA keys for ${succeeded} org(s).`
);
}
if (failedOrgIds.length > 0) {
console.error(
`No CA was generated for ${failedOrgIds.length} organization(s): ${failedOrgIds.join(
", "
)}`
);
}
} catch (e) {
console.error(
"Error while generating SSH CA keys for orgs after migration:",
e
);
}
console.log(`${version} migration complete`);
}
+8 -152
View File
@@ -1,167 +1,23 @@
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { encrypt } from "@server/lib/crypto";
import { generateCA } from "@server/lib/sshCA";
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
import yaml from "js-yaml";
const version = "1.16.0";
function getServerSecret(): string {
const envSecret = process.env.SERVER_SECRET;
const configPath = fs.existsSync(configFilePath1)
? configFilePath1
: fs.existsSync(configFilePath2)
? configFilePath2
: null;
// If no config file but an env secret is set, use the env secret directly
if (!configPath) {
if (envSecret && envSecret.length > 0) {
return envSecret;
}
throw new Error(
"Cannot generate org CA keys: no config file found and SERVER_SECRET env var is not set. " +
"Expected config.yml or config.yaml in the config directory, or set SERVER_SECRET."
);
}
const configContent = fs.readFileSync(configPath, "utf8");
const config = yaml.load(configContent) as {
server?: { secret?: string };
};
let secret = config?.server?.secret;
if (!secret || secret.length === 0) {
// Fall back to SERVER_SECRET env var if config does not contain server.secret
if (envSecret && envSecret.length > 0) {
secret = envSecret;
}
}
if (!secret || secret.length === 0) {
throw new Error(
"Cannot generate org CA keys: no server.secret in config and SERVER_SECRET env var is not set. " +
"Set server.secret in config.yml/config.yaml or set SERVER_SECRET."
);
}
return secret;
}
export default async function migration() {
console.log(`Running setup script ${version}...`);
// Ensure server secret exists before running migration (required for org CA key generation)
getServerSecret();
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
// set all admin role sudo to "full"; all other roles to "none"
// all roles set hoemdir to true
// generate ca certs for all orgs?
// set authDaemonMode to "site" for all site-resources
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
// Create roundTripMessageTracker table for tracking message round-trips
db.prepare(
`
CREATE TABLE 'roundTripMessageTracker' (
'messageId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'clientId' text,
'messageType' text,
'sentAt' integer NOT NULL,
'receivedAt' integer,
'error' text,
'complete' integer DEFAULT 0 NOT NULL
);
`
).run();
// Org SSH CA and billing columns
db.prepare(`ALTER TABLE 'orgs' ADD 'sshCaPrivateKey' text;`).run();
db.prepare(`ALTER TABLE 'orgs' ADD 'sshCaPublicKey' text;`).run();
db.prepare(`ALTER TABLE 'orgs' ADD 'isBillingOrg' integer;`).run();
db.prepare(`ALTER TABLE 'orgs' ADD 'billingOrgId' text;`).run();
// Role SSH sudo and unix group columns
db.prepare(
`ALTER TABLE 'roles' ADD 'sshSudoMode' text DEFAULT 'none';`
).run();
db.prepare(
`ALTER TABLE 'roles' ADD 'sshSudoCommands' text DEFAULT '[]';`
).run();
db.prepare(
`ALTER TABLE 'roles' ADD 'sshCreateHomeDir' integer DEFAULT 1;`
).run();
db.prepare(
`ALTER TABLE 'roles' ADD 'sshUnixGroups' text DEFAULT '[]';`
).run();
// Site resource auth daemon columns
db.prepare(
`ALTER TABLE 'siteResources' ADD 'authDaemonPort' integer DEFAULT 22123;`
).run();
db.prepare(
`ALTER TABLE 'siteResources' ADD 'authDaemonMode' text DEFAULT 'site';`
).run();
// UserOrg PAM username for SSH
db.prepare(`ALTER TABLE 'userOrgs' ADD 'pamUsername' text;`).run();
// Set all admin role sudo to "full"; other roles keep default "none"
db.prepare(
`UPDATE 'roles' SET 'sshSudoMode' = 'full' WHERE isAdmin = 1;`
).run();
})();
db.pragma("foreign_keys = ON");
const orgRows = db.prepare("SELECT orgId FROM orgs").all() as {
orgId: string;
}[];
// Generate and store encrypted SSH CA keys for all orgs
const secret = getServerSecret();
const updateOrgCaKeys = db.prepare(
"UPDATE orgs SET sshCaPrivateKey = ?, sshCaPublicKey = ? WHERE orgId = ?"
);
const failedOrgIds: string[] = [];
for (const row of orgRows) {
try {
const ca = generateCA(`pangolin-ssh-ca-${row.orgId}`);
const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret);
updateOrgCaKeys.run(
encryptedPrivateKey,
ca.publicKeyOpenSSH,
row.orgId
);
} catch (err) {
failedOrgIds.push(row.orgId);
console.error(
`Error: No CA was generated for organization "${row.orgId}".`,
err instanceof Error ? err.message : err
);
}
}
if (orgRows.length > 0) {
const succeeded = orgRows.length - failedOrgIds.length;
console.log(
`Generated and stored SSH CA keys for ${succeeded} org(s).`
);
}
if (failedOrgIds.length > 0) {
console.error(
`No CA was generated for ${failedOrgIds.length} organization(s): ${failedOrgIds.join(", ")}`
);
}
db.transaction(() => {})();
console.log(`Migrated database`);
} catch (e) {
@@ -210,8 +210,7 @@ export default function BillingPage() {
({ subscription }) =>
subscription?.type === "tier1" ||
subscription?.type === "tier2" ||
subscription?.type === "tier3" ||
subscription?.type === "enterprise"
subscription?.type === "tier3"
);
setTierSubscription(tierSub || null);
@@ -440,8 +439,6 @@ export default function BillingPage() {
// Get current plan ID from tier
const getCurrentPlanId = (): PlanId => {
if (!hasSubscription || !currentTier) return "basic";
// Handle enterprise subscription type directly
if (currentTier === "enterprise") return "enterprise";
const plan = planOptions.find((p) => p.tierType === currentTier);
return plan?.id || "basic";
};
@@ -187,11 +187,7 @@ export default function ResourceAuthenticationPage() {
number | null
>(null);
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
useEffect(() => {
setSsoEnabled(resource.sso ?? false);
}, [resource.sso]);
const [ssoEnabled, setSsoEnabled] = useState(resource.sso);
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
resource.skipToIdpId || null
@@ -476,7 +472,7 @@ export default function ResourceAuthenticationPage() {
<SwitchInput
id="sso-toggle"
label={t("ssoUse")}
checked={ssoEnabled}
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
@@ -804,13 +800,8 @@ function OneTimePasswordFormSection({
}: OneTimePasswordFormSectionProps) {
const { env } = useEnvContext();
const [whitelistEnabled, setWhitelistEnabled] = useState(
resource.emailWhitelistEnabled ?? false
resource.emailWhitelistEnabled
);
useEffect(() => {
setWhitelistEnabled(resource.emailWhitelistEnabled);
}, [resource.emailWhitelistEnabled]);
const queryClient = useQueryClient();
const [loadingSaveWhitelist, startTransition] = useTransition();
@@ -903,7 +894,7 @@ function OneTimePasswordFormSection({
<SwitchInput
id="whitelist-toggle"
label={t("otpEmailWhitelist")}
checked={whitelistEnabled}
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>
@@ -113,12 +113,7 @@ export default function ResourceRules(props: {
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false);
useEffect(() => {
setRulesEnabled(resource.applyRules);
}, [resource.applyRules]);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
const [openCountrySelect, setOpenCountrySelect] = useState(false);
const [countrySelectValue, setCountrySelectValue] = useState("");
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
@@ -841,7 +836,7 @@ export default function ResourceRules(props: {
<SwitchInput
id="rules-toggle"
label={t("rulesEnable")}
checked={rulesEnabled}
defaultChecked={rulesEnabled}
onCheckedChange={(val) => setRulesEnabled(val)}
/>
</div>
+3 -4
View File
@@ -3,12 +3,11 @@ import { redirect } from "next/navigation";
import DeviceLoginForm from "@/components/DeviceLoginForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cache } from "react";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
type Props = {
searchParams: Promise<{ code?: string; user?: string; authPath?: string }>;
searchParams: Promise<{ code?: string; user?: string }>;
};
function deviceRedirectSearchParams(params: {
@@ -31,11 +30,11 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
if (!user) {
const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`;
const authPath = cleanRedirect(params.authPath || "/auth/login");
const loginUrl = new URL(authPath, "http://x");
const loginUrl = new URL("/auth/login", "http://x");
loginUrl.searchParams.set("forceLogin", "true");
loginUrl.searchParams.set("redirect", redirectDestination);
if (defaultUser) loginUrl.searchParams.set("user", defaultUser);
console.log("loginUrl", loginUrl.pathname + loginUrl.search);
redirect(loginUrl.pathname + loginUrl.search);
}
+1 -1
View File
@@ -107,7 +107,7 @@ export const orgNavSections = (
]
},
{
heading: "accessControl",
heading: "access",
items: [
{
title: "sidebarTeam",
+2 -37
View File
@@ -15,15 +15,7 @@ import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
ArrowUpRight,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -141,26 +133,7 @@ export default function ClientResourcesTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
header: () => <span className="p-3">{t("name")}</span>
},
{
id: "niceId",
@@ -356,14 +329,6 @@ export default function ClientResourcesTable({
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
+12 -12
View File
@@ -31,18 +31,6 @@ const CopyToClipboard = ({
return (
<div className="flex items-center space-x-2 min-w-0 max-w-full">
<button
type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy}
>
{!copied ? (
<Copy className="h-4 w-4" />
) : (
<Check className="text-green-500 h-4 w-4" />
)}
<span className="sr-only">{t("copyText")}</span>
</button>
{isLink ? (
<Link
href={text}
@@ -66,6 +54,18 @@ const CopyToClipboard = ({
{displayValue}
</span>
)}
<button
type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy}
>
{!copied ? (
<Copy className="h-4 w-4" />
) : (
<Check className="text-green-500 h-4 w-4" />
)}
<span className="sr-only">{t("copyText")}</span>
</button>
</div>
);
};
+2 -2
View File
@@ -640,7 +640,7 @@ export function InternalResourceForm({
title: t("editInternalResourceDialogAccessPolicy"),
href: "#"
},
...(disableEnterpriseFeatures || mode === "cidr"
...(disableEnterpriseFeatures
? []
: [{ title: t("sshAccess"), href: "#" }])
]}
@@ -1188,7 +1188,7 @@ export function InternalResourceForm({
</div>
{/* SSH Access tab */}
{!disableEnterpriseFeatures && mode !== "cidr" && (
{!disableEnterpriseFeatures && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8">
+1 -20
View File
@@ -204,26 +204,7 @@ export default function MachineClientsTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("name")}
className="px-3"
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
header: () => <span className="px-3">{t("name")}</span>,
cell: ({ row }) => {
const r = row.original;
return (
+1 -32
View File
@@ -14,19 +14,15 @@ import { InfoPopup } from "@app/components/ui/info-popup";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
CheckCircle2,
ChevronDown,
ChevronsUpDownIcon,
Clock,
MoreHorizontal,
ShieldCheck,
@@ -322,26 +318,7 @@ export default function ProxyResourcesTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
header: () => <span className="p-3">{t("name")}</span>
},
{
id: "niceId",
@@ -586,14 +563,6 @@ export default function ProxyResourcesTable({
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
+1 -18
View File
@@ -141,24 +141,7 @@ export default function SitesTable({
accessorKey: "name",
enableHiding: false,
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
return <span className="p-3">{t("name")}</span>;
}
},
{
+22 -11
View File
@@ -2,20 +2,31 @@ import { headers } from "next/headers";
export async function authCookieHeader() {
const otherHeaders = await headers();
const otherHeadersObject = Object.fromEntries(
Array.from(otherHeaders.entries()).map(([k, v]) => [k.toLowerCase(), v])
);
const otherHeadersObject = Object.fromEntries(otherHeaders.entries());
return {
headers: {
cookie: otherHeadersObject["cookie"],
host: otherHeadersObject["host"],
"user-agent": otherHeadersObject["user-agent"],
"x-forwarded-for": otherHeadersObject["x-forwarded-for"],
"x-forwarded-host": otherHeadersObject["x-forwarded-host"],
"x-forwarded-port": otherHeadersObject["x-forwarded-port"],
"x-forwarded-proto": otherHeadersObject["x-forwarded-proto"],
"x-real-ip": otherHeadersObject["x-real-ip"]
cookie:
otherHeadersObject["cookie"] || otherHeadersObject["Cookie"],
host: otherHeadersObject["host"] || otherHeadersObject["Host"],
"user-agent":
otherHeadersObject["user-agent"] ||
otherHeadersObject["User-Agent"],
"x-forwarded-for":
otherHeadersObject["x-forwarded-for"] ||
otherHeadersObject["X-Forwarded-For"],
"x-forwarded-host":
otherHeadersObject["fx-forwarded-host"] ||
otherHeadersObject["Fx-Forwarded-Host"],
"x-forwarded-port":
otherHeadersObject["x-forwarded-port"] ||
otherHeadersObject["X-Forwarded-Port"],
"x-forwarded-proto":
otherHeadersObject["x-forwarded-proto"] ||
otherHeadersObject["X-Forwarded-Proto"],
"x-real-ip":
otherHeadersObject["x-real-ip"] ||
otherHeadersObject["X-Real-IP"]
}
};
}