mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-26 14:56:39 +00:00
Compare commits
83 Commits
1.15.4-s.8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9700d84c | ||
|
|
f8a8cdaa5f | ||
|
|
e23e446476 | ||
|
|
fa097df50b | ||
|
|
75f34ff127 | ||
|
|
c9586b4d93 | ||
|
|
52937a6d90 | ||
|
|
186c131cce | ||
|
|
8de3f9a440 | ||
|
|
ea49e179f9 | ||
|
|
485f4f1c8e | ||
|
|
5fb35d12d7 | ||
|
|
ec8a9fe3d2 | ||
|
|
411a34e15e | ||
|
|
3df71fd2bc | ||
|
|
5e1f6085e3 | ||
|
|
53fc7ab6e3 | ||
|
|
7779ed24fe | ||
|
|
6e4193dae3 | ||
|
|
f138609f48 | ||
|
|
98154b5de3 | ||
|
|
6322fd9eef | ||
|
|
1c0949e957 | ||
|
|
c3847e6001 | ||
|
|
5cf13a963d | ||
|
|
b017877826 | ||
|
|
959f68b520 | ||
|
|
14cab3fdb8 | ||
|
|
b8d468f6de | ||
|
|
fc66394243 | ||
|
|
8fca243c9a | ||
|
|
388f710379 | ||
|
|
ba3ab4362b | ||
|
|
e18c9afc2d | ||
|
|
a9b4a86c4a | ||
|
|
200ea502dd | ||
|
|
de36db97eb | ||
|
|
30283b044f | ||
|
|
055bed8a07 | ||
|
|
12b5c2ab34 | ||
|
|
dd78674888 | ||
|
|
0d0df63847 | ||
|
|
3ab00d9da8 | ||
|
|
3e6e72c5c7 | ||
|
|
5d8a55f08c | ||
|
|
81c569aae4 | ||
|
|
88fd3fc4da | ||
|
|
2282d3ae39 | ||
|
|
c4dcec463a | ||
|
|
5b7f893ad7 | ||
|
|
2ede0d498a | ||
|
|
f518e8a0ff | ||
|
|
767284408a | ||
|
|
eef51f3b84 | ||
|
|
69b7114a49 | ||
|
|
0ea38ea568 | ||
|
|
c600da71e3 | ||
|
|
c64dd14b1a | ||
|
|
8ea6d9fa67 | ||
|
|
978ac8f53c | ||
|
|
49a326cde7 | ||
|
|
63e208f4ec | ||
|
|
f50d1549b0 | ||
|
|
55e24df671 | ||
|
|
b37e1d0cc0 | ||
|
|
afa26c0dd4 | ||
|
|
c71f46ede5 | ||
|
|
2edebaddc2 | ||
|
|
119e1d4867 | ||
|
|
63e30d3378 | ||
|
|
d6fe04ec4e | ||
|
|
848d4d91e6 | ||
|
|
720d3a8135 | ||
|
|
9c42458fa5 | ||
|
|
bcd3475d17 | ||
|
|
e8398cb221 | ||
|
|
9460e28c7b | ||
|
|
d8b45396e3 | ||
|
|
952d0c74d0 | ||
|
|
ffbea7af59 | ||
|
|
971c375398 | ||
|
|
ac4439c5ae | ||
|
|
8c15855fc3 |
@@ -28,9 +28,9 @@ LICENSE
|
|||||||
CONTRIBUTING.md
|
CONTRIBUTING.md
|
||||||
dist
|
dist
|
||||||
.git
|
.git
|
||||||
migrations/
|
server/migrations/
|
||||||
config/
|
config/
|
||||||
build.ts
|
build.ts
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
migrations/
|
drizzle.config.ts
|
||||||
|
|||||||
38
.github/workflows/cicd.yml
vendored
38
.github/workflows/cicd.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
permissions: write-all
|
permissions: write-all
|
||||||
steps:
|
steps:
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
role-duration-seconds: 3600
|
role-duration-seconds: 3600
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -201,10 +201,10 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Extract tag name
|
- name: Extract tag name
|
||||||
id: get-tag
|
id: get-tag
|
||||||
@@ -289,22 +289,14 @@ jobs:
|
|||||||
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||||
shell: bash
|
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
|
- name: Build installer
|
||||||
working-directory: install
|
working-directory: install
|
||||||
run: |
|
run: |
|
||||||
make go-build-release
|
make go-build-release \
|
||||||
|
PANGOLIN_VERSION=${{ env.TAG }} \
|
||||||
|
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} \
|
||||||
|
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Upload artifacts from /install/bin
|
- name: Upload artifacts from /install/bin
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
@@ -415,7 +407,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry (for cosign)
|
- name: Login to GitHub Container Registry (for cosign)
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -578,7 +570,7 @@ jobs:
|
|||||||
permissions: write-all
|
permissions: write-all
|
||||||
steps:
|
steps:
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
role-duration-seconds: 3600
|
role-duration-seconds: 3600
|
||||||
|
|||||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
|
|||||||
2
.github/workflows/restart-runners.yml
vendored
2
.github/workflows/restart-runners.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
permissions: write-all
|
permissions: write-all
|
||||||
steps:
|
steps:
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
role-duration-seconds: 3600
|
role-duration-seconds: 3600
|
||||||
|
|||||||
8
.github/workflows/saas.yml
vendored
8
.github/workflows/saas.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
permissions: write-all
|
permissions: write-all
|
||||||
steps:
|
steps:
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
role-duration-seconds: 3600
|
role-duration-seconds: 3600
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Download MaxMind GeoLite2 databases
|
- name: Download MaxMind GeoLite2 databases
|
||||||
env:
|
env:
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
|
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
role-duration-seconds: 3600
|
role-duration-seconds: 3600
|
||||||
@@ -145,7 +145,7 @@ jobs:
|
|||||||
permissions: write-all
|
permissions: write-all
|
||||||
steps:
|
steps:
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
role-duration-seconds: 3600
|
role-duration-seconds: 3600
|
||||||
|
|||||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||||
with:
|
with:
|
||||||
days-before-stale: 14
|
days-before-stale: 14
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Build Docker image sqlite
|
- name: Build Docker image sqlite
|
||||||
run: make dev-build-sqlite
|
run: make dev-build-sqlite
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Build Docker image pg
|
- name: Build Docker image pg
|
||||||
run: make dev-build-pg
|
run: make dev-build-pg
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -1,8 +1,8 @@
|
|||||||
FROM node:24-alpine AS base
|
FROM node:24-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
@@ -23,15 +23,19 @@ RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
|||||||
npm run build:cli && \
|
npm run build:cli && \
|
||||||
test -f dist/server.mjs
|
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
|
FROM base AS builder
|
||||||
|
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-slim AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache curl tzdata
|
RUN apt-get update && apt-get install -y curl tzdata && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/package.json ./package.json
|
COPY --from=builder /app/package.json ./package.json
|
||||||
@@ -51,12 +55,16 @@ COPY public ./public
|
|||||||
|
|
||||||
# Copy MaxMind databases for SaaS builds
|
# Copy MaxMind databases for SaaS builds
|
||||||
ARG BUILD=oss
|
ARG BUILD=oss
|
||||||
|
|
||||||
RUN mkdir -p ./maxmind
|
RUN mkdir -p ./maxmind
|
||||||
|
|
||||||
# This is only for saas
|
# Copy MaxMind databases (placeholders exist for oss builds, real files for saas)
|
||||||
COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb
|
COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb
|
||||||
COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.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
|
# OCI Image Labels - Build Args for dynamic values
|
||||||
ARG VERSION="dev"
|
ARG VERSION="dev"
|
||||||
ARG REVISION=""
|
ARG REVISION=""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { db, orgs } from "@server/db";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { generateCA } from "@server/private/lib/sshCA";
|
import { generateCA } from "@server/lib/sshCA";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,24 @@
|
|||||||
all: update-versions go-build-release put-back
|
all: go-build-release
|
||||||
dev-all: dev-update-versions dev-build dev-clean
|
|
||||||
|
# Build with version injection via ldflags
|
||||||
|
# Versions can be passed via: make go-build-release PANGOLIN_VERSION=x.x.x GERBIL_VERSION=x.x.x BADGER_VERSION=x.x.x
|
||||||
|
# Or fetched automatically if not provided (requires curl and jq)
|
||||||
|
|
||||||
|
PANGOLIN_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name')
|
||||||
|
GERBIL_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name')
|
||||||
|
BADGER_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name')
|
||||||
|
|
||||||
|
LDFLAGS = -X main.pangolinVersion=$(PANGOLIN_VERSION) \
|
||||||
|
-X main.gerbilVersion=$(GERBIL_VERSION) \
|
||||||
|
-X main.badgerVersion=$(BADGER_VERSION)
|
||||||
|
|
||||||
go-build-release:
|
go-build-release:
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
@echo "Building with versions - Pangolin: $(PANGOLIN_VERSION), Gerbil: $(GERBIL_VERSION), Badger: $(BADGER_VERSION)"
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_amd64
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_arm64
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f bin/installer_linux_amd64
|
rm -f bin/installer_linux_amd64
|
||||||
rm -f bin/installer_linux_arm64
|
rm -f bin/installer_linux_arm64
|
||||||
|
|
||||||
update-versions:
|
.PHONY: all go-build-release clean
|
||||||
@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"
|
|
||||||
|
|||||||
@@ -118,19 +118,19 @@ func copyDockerService(sourceFile, destFile, serviceName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse source Docker Compose YAML
|
// Parse source Docker Compose YAML
|
||||||
var sourceCompose map[string]interface{}
|
var sourceCompose map[string]any
|
||||||
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
|
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
|
||||||
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
|
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse destination Docker Compose YAML
|
// Parse destination Docker Compose YAML
|
||||||
var destCompose map[string]interface{}
|
var destCompose map[string]any
|
||||||
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
|
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
|
||||||
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
|
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get services section from source
|
// Get services section from source
|
||||||
sourceServices, ok := sourceCompose["services"].(map[string]interface{})
|
sourceServices, ok := sourceCompose["services"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("services section not found in source file or has invalid format")
|
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
|
// Get or create services section in destination
|
||||||
destServices, ok := destCompose["services"].(map[string]interface{})
|
destServices, ok := destCompose["services"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
// If services section doesn't exist, create it
|
// If services section doesn't exist, create it
|
||||||
destServices = make(map[string]interface{})
|
destServices = make(map[string]any)
|
||||||
destCompose["services"] = destServices
|
destCompose["services"] = destServices
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,13 +187,12 @@ func backupConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) {
|
func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) {
|
||||||
buffer := new(bytes.Buffer)
|
buffer := new(bytes.Buffer)
|
||||||
encoder := yaml.NewEncoder(buffer)
|
encoder := yaml.NewEncoder(buffer)
|
||||||
encoder.SetIndent(indent)
|
encoder.SetIndent(indent)
|
||||||
|
|
||||||
err := encoder.Encode(data)
|
if err := encoder.Encode(data); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +208,7 @@ func replaceInFile(filepath, oldStr, newStr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Replace the string
|
// Replace the string
|
||||||
newContent := strings.Replace(string(content), oldStr, newStr, -1)
|
newContent := strings.ReplaceAll(string(content), oldStr, newStr)
|
||||||
|
|
||||||
// Write the modified content back to the file
|
// Write the modified content back to the file
|
||||||
err = os.WriteFile(filepath, []byte(newContent), 0644)
|
err = os.WriteFile(filepath, []byte(newContent), 0644)
|
||||||
@@ -228,28 +227,28 @@ func CheckAndAddTraefikLogVolume(composePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse YAML into a generic map
|
// Parse YAML into a generic map
|
||||||
var compose map[string]interface{}
|
var compose map[string]any
|
||||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||||
return fmt.Errorf("error parsing compose file: %w", err)
|
return fmt.Errorf("error parsing compose file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get services section
|
// Get services section
|
||||||
services, ok := compose["services"].(map[string]interface{})
|
services, ok := compose["services"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("services section not found or invalid")
|
return fmt.Errorf("services section not found or invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get traefik service
|
// Get traefik service
|
||||||
traefik, ok := services["traefik"].(map[string]interface{})
|
traefik, ok := services["traefik"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("traefik service not found or invalid")
|
return fmt.Errorf("traefik service not found or invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check volumes
|
// Check volumes
|
||||||
logVolume := "./config/traefik/logs:/var/log/traefik"
|
logVolume := "./config/traefik/logs:/var/log/traefik"
|
||||||
var volumes []interface{}
|
var volumes []any
|
||||||
|
|
||||||
if existingVolumes, ok := traefik["volumes"].([]interface{}); ok {
|
if existingVolumes, ok := traefik["volumes"].([]any); ok {
|
||||||
// Check if volume already exists
|
// Check if volume already exists
|
||||||
for _, v := range existingVolumes {
|
for _, v := range existingVolumes {
|
||||||
if v.(string) == logVolume {
|
if v.(string) == logVolume {
|
||||||
@@ -295,13 +294,13 @@ func MergeYAML(baseFile, overlayFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse base YAML into a map
|
// Parse base YAML into a map
|
||||||
var baseMap map[string]interface{}
|
var baseMap map[string]any
|
||||||
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
|
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
|
||||||
return fmt.Errorf("error parsing base YAML: %v", err)
|
return fmt.Errorf("error parsing base YAML: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse overlay YAML into a map
|
// Parse overlay YAML into a map
|
||||||
var overlayMap map[string]interface{}
|
var overlayMap map[string]any
|
||||||
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
|
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
|
||||||
return fmt.Errorf("error parsing overlay YAML: %v", err)
|
return fmt.Errorf("error parsing overlay YAML: %v", err)
|
||||||
}
|
}
|
||||||
@@ -324,8 +323,8 @@ func MergeYAML(baseFile, overlayFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mergeMap recursively merges two maps
|
// mergeMap recursively merges two maps
|
||||||
func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
|
func mergeMap(base, overlay map[string]any) map[string]any {
|
||||||
result := make(map[string]interface{})
|
result := make(map[string]any)
|
||||||
|
|
||||||
// Copy all key-values from base map
|
// Copy all key-values from base map
|
||||||
for k, v := range base {
|
for k, v := range base {
|
||||||
@@ -336,8 +335,8 @@ func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
|
|||||||
for k, v := range overlay {
|
for k, v := range overlay {
|
||||||
// If both maps have the same key and both values are maps, merge recursively
|
// If both maps have the same key and both values are maps, merge recursively
|
||||||
if baseVal, ok := base[k]; ok {
|
if baseVal, ok := base[k]; ok {
|
||||||
if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap {
|
if baseMap, isBaseMap := baseVal.(map[string]any); isBaseMap {
|
||||||
if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap {
|
if overlayMap, isOverlayMap := v.(map[string]any); isOverlayMap {
|
||||||
result[k] = mergeMap(baseMap, overlayMap)
|
result[k] = mergeMap(baseMap, overlayMap)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ services:
|
|||||||
image: docker.io/traefik:v3.6
|
image: docker.io/traefik:v3.6
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}}
|
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
|
||||||
{{end}}{{if not .InstallGerbil}}
|
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80
|
- 80:80
|
||||||
|
|||||||
@@ -144,12 +144,13 @@ func installDocker() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startDockerService() error {
|
func startDockerService() error {
|
||||||
if runtime.GOOS == "linux" {
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
cmd := exec.Command("systemctl", "enable", "--now", "docker")
|
cmd := exec.Command("systemctl", "enable", "--now", "docker")
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
} else if runtime.GOOS == "darwin" {
|
case "darwin":
|
||||||
// On macOS, Docker is usually started via the Docker Desktop application
|
// On macOS, Docker is usually started via the Docker Desktop application
|
||||||
fmt.Println("Please start Docker Desktop manually on macOS.")
|
fmt.Println("Please start Docker Desktop manually on macOS.")
|
||||||
return nil
|
return nil
|
||||||
@@ -302,7 +303,7 @@ func pullContainers(containerType SupportedContainer) error {
|
|||||||
return nil
|
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.
|
// startContainers starts the containers using the appropriate command.
|
||||||
@@ -325,7 +326,7 @@ func startContainers(containerType SupportedContainer) error {
|
|||||||
return nil
|
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.
|
// stopContainers stops the containers using the appropriate command.
|
||||||
@@ -347,7 +348,7 @@ func stopContainers(containerType SupportedContainer) error {
|
|||||||
return nil
|
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.
|
// restartContainer restarts a specific container using the appropriate command.
|
||||||
@@ -369,5 +370,5 @@ func restartContainer(container string, containerType SupportedContainer) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("Unsupported container type: %s", containerType)
|
return fmt.Errorf("unsupported container type: %s", containerType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,18 @@ func installCrowdsec(config Config) error {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
os.MkdirAll("config/crowdsec/db", 0755)
|
if err := os.MkdirAll("config/crowdsec/db", 0755); err != nil {
|
||||||
os.MkdirAll("config/crowdsec/acquis.d", 0755)
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
os.MkdirAll("config/traefik/logs", 0755)
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("config/crowdsec/acquis.d", 0755); err != nil {
|
||||||
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("config/traefik/logs", 0755); err != nil {
|
||||||
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
||||||
fmt.Printf("Error copying docker service: %v\n", err)
|
fmt.Printf("Error copying docker service: %v\n", err)
|
||||||
@@ -153,34 +162,34 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse YAML into a generic map
|
// Parse YAML into a generic map
|
||||||
var compose map[string]interface{}
|
var compose map[string]any
|
||||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||||
return fmt.Errorf("error parsing compose file: %w", err)
|
return fmt.Errorf("error parsing compose file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get services section
|
// Get services section
|
||||||
services, ok := compose["services"].(map[string]interface{})
|
services, ok := compose["services"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("services section not found or invalid")
|
return fmt.Errorf("services section not found or invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get traefik service
|
// Get traefik service
|
||||||
traefik, ok := services["traefik"].(map[string]interface{})
|
traefik, ok := services["traefik"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("traefik service not found or invalid")
|
return fmt.Errorf("traefik service not found or invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get dependencies
|
// Get dependencies
|
||||||
dependsOn, ok := traefik["depends_on"].(map[string]interface{})
|
dependsOn, ok := traefik["depends_on"].(map[string]any)
|
||||||
if ok {
|
if ok {
|
||||||
// Append the new block for crowdsec
|
// Append the new block for crowdsec
|
||||||
dependsOn["crowdsec"] = map[string]interface{}{
|
dependsOn["crowdsec"] = map[string]any{
|
||||||
"condition": "service_healthy",
|
"condition": "service_healthy",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No dependencies exist, create it
|
// No dependencies exist, create it
|
||||||
traefik["depends_on"] = map[string]interface{}{
|
traefik["depends_on"] = map[string]any{
|
||||||
"crowdsec": map[string]interface{}{
|
"crowdsec": map[string]any{
|
||||||
"condition": "service_healthy",
|
"condition": "service_healthy",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,36 @@ module installer
|
|||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/term v0.39.0
|
github.com/charmbracelet/huh v0.8.0
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
golang.org/x/term v0.40.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.40.0 // indirect
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,80 @@
|
|||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||||
|
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||||
|
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
285
install/input.go
285
install/input.go
@@ -1,92 +1,235 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"os"
|
||||||
"syscall"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readString(reader *bufio.Reader, prompt string, defaultValue string) string {
|
// pangolinTheme is the custom theme using brand colors
|
||||||
|
var pangolinTheme = ThemePangolin()
|
||||||
|
|
||||||
|
// isAccessibleMode checks if we should use accessible mode (simple prompts)
|
||||||
|
// This is true for: non-TTY, TERM=dumb, or ACCESSIBLE env var set
|
||||||
|
func isAccessibleMode() bool {
|
||||||
|
// Check if stdin is not a terminal (piped input, CI, etc.)
|
||||||
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Check for dumb terminal
|
||||||
|
if os.Getenv("TERM") == "dumb" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Check for explicit accessible mode request
|
||||||
|
if os.Getenv("ACCESSIBLE") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAbort checks if the error is a user abort (Ctrl+C) and exits if so
|
||||||
|
func handleAbort(err error) {
|
||||||
|
if err != nil && errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
fmt.Println("\nInstallation cancelled.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runField runs a single field with the Pangolin theme, handling accessible mode
|
||||||
|
func runField(field huh.Field) error {
|
||||||
|
if isAccessibleMode() {
|
||||||
|
return field.RunAccessible(os.Stdout, os.Stdin)
|
||||||
|
}
|
||||||
|
form := huh.NewForm(huh.NewGroup(field)).WithTheme(pangolinTheme)
|
||||||
|
return form.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readString(prompt string, defaultValue string) string {
|
||||||
|
var value string
|
||||||
|
|
||||||
|
title := prompt
|
||||||
if defaultValue != "" {
|
if defaultValue != "" {
|
||||||
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
|
title = fmt.Sprintf("%s (default: %s)", prompt, defaultValue)
|
||||||
} else {
|
|
||||||
fmt.Print(prompt + ": ")
|
|
||||||
}
|
}
|
||||||
input, _ := reader.ReadString('\n')
|
|
||||||
input = strings.TrimSpace(input)
|
|
||||||
if input == "" {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
func readStringNoDefault(reader *bufio.Reader, prompt string) string {
|
input := huh.NewInput().
|
||||||
fmt.Print(prompt + ": ")
|
Title(title).
|
||||||
input, _ := reader.ReadString('\n')
|
Value(&value)
|
||||||
return strings.TrimSpace(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readPassword(prompt string, reader *bufio.Reader) string {
|
// If no default value, this field is required
|
||||||
if term.IsTerminal(int(syscall.Stdin)) {
|
if defaultValue == "" {
|
||||||
fmt.Print(prompt + ": ")
|
input = input.Validate(func(s string) error {
|
||||||
// Read password without echo if we're in a terminal
|
if s == "" {
|
||||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
return fmt.Errorf("this field is required")
|
||||||
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
input := strings.TrimSpace(string(password))
|
return nil
|
||||||
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 {
|
err := runField(input)
|
||||||
defaultStr := "no"
|
handleAbort(err)
|
||||||
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 {
|
if value == "" {
|
||||||
for {
|
value = defaultValue
|
||||||
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 {
|
// Print the answer so it remains visible in terminal history (skip in accessible mode as it already shows)
|
||||||
input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue))
|
if !isAccessibleMode() {
|
||||||
if input == "" {
|
fmt.Printf("%s: %s\n", prompt, value)
|
||||||
return defaultValue
|
|
||||||
}
|
}
|
||||||
value := defaultValue
|
|
||||||
fmt.Sscanf(input, "%d", &value)
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readStringNoDefault(prompt string) string {
|
||||||
|
var value string
|
||||||
|
|
||||||
|
for {
|
||||||
|
input := huh.NewInput().
|
||||||
|
Title(prompt).
|
||||||
|
Value(&value).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if s == "" {
|
||||||
|
return fmt.Errorf("this field is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := runField(input)
|
||||||
|
handleAbort(err)
|
||||||
|
|
||||||
|
if value != "" {
|
||||||
|
// Print the answer so it remains visible in terminal history
|
||||||
|
if !isAccessibleMode() {
|
||||||
|
fmt.Printf("%s: %s\n", prompt, value)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPassword(prompt string) string {
|
||||||
|
var value string
|
||||||
|
|
||||||
|
for {
|
||||||
|
input := huh.NewInput().
|
||||||
|
Title(prompt).
|
||||||
|
Value(&value).
|
||||||
|
EchoMode(huh.EchoModePassword).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if s == "" {
|
||||||
|
return fmt.Errorf("password is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := runField(input)
|
||||||
|
handleAbort(err)
|
||||||
|
|
||||||
|
if value != "" {
|
||||||
|
// Print confirmation without revealing the password
|
||||||
|
if !isAccessibleMode() {
|
||||||
|
fmt.Printf("%s: %s\n", prompt, "********")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBool(prompt string, defaultValue bool) bool {
|
||||||
|
var value = defaultValue
|
||||||
|
|
||||||
|
confirm := huh.NewConfirm().
|
||||||
|
Title(prompt).
|
||||||
|
Value(&value).
|
||||||
|
Affirmative("Yes").
|
||||||
|
Negative("No")
|
||||||
|
|
||||||
|
err := runField(confirm)
|
||||||
|
handleAbort(err)
|
||||||
|
|
||||||
|
// Print the answer so it remains visible in terminal history
|
||||||
|
if !isAccessibleMode() {
|
||||||
|
answer := "No"
|
||||||
|
if value {
|
||||||
|
answer = "Yes"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s\n", prompt, answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBoolNoDefault(prompt string) bool {
|
||||||
|
var value bool
|
||||||
|
|
||||||
|
confirm := huh.NewConfirm().
|
||||||
|
Title(prompt).
|
||||||
|
Value(&value).
|
||||||
|
Affirmative("Yes").
|
||||||
|
Negative("No")
|
||||||
|
|
||||||
|
err := runField(confirm)
|
||||||
|
handleAbort(err)
|
||||||
|
|
||||||
|
// Print the answer so it remains visible in terminal history
|
||||||
|
if !isAccessibleMode() {
|
||||||
|
answer := "No"
|
||||||
|
if value {
|
||||||
|
answer = "Yes"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s\n", prompt, answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInt(prompt string, defaultValue int) int {
|
||||||
|
var value string
|
||||||
|
|
||||||
|
title := fmt.Sprintf("%s (default: %d)", prompt, defaultValue)
|
||||||
|
|
||||||
|
input := huh.NewInput().
|
||||||
|
Title(title).
|
||||||
|
Value(&value).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("please enter a valid number")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := runField(input)
|
||||||
|
handleAbort(err)
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
// Print the answer so it remains visible in terminal history
|
||||||
|
if !isAccessibleMode() {
|
||||||
|
fmt.Printf("%s: %d\n", prompt, defaultValue)
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
if !isAccessibleMode() {
|
||||||
|
fmt.Printf("%s: %d\n", prompt, defaultValue)
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the answer so it remains visible in terminal history
|
||||||
|
if !isAccessibleMode() {
|
||||||
|
fmt.Printf("%s: %d\n", prompt, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
132
install/main.go
132
install/main.go
@@ -1,13 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"crypto/rand"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -20,11 +19,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
|
// Version variables injected at build time via -ldflags
|
||||||
|
var (
|
||||||
|
pangolinVersion string
|
||||||
|
gerbilVersion string
|
||||||
|
badgerVersion string
|
||||||
|
)
|
||||||
|
|
||||||
func loadVersions(config *Config) {
|
func loadVersions(config *Config) {
|
||||||
config.PangolinVersion = "replaceme"
|
config.PangolinVersion = pangolinVersion
|
||||||
config.GerbilVersion = "replaceme"
|
config.GerbilVersion = gerbilVersion
|
||||||
config.BadgerVersion = "replaceme"
|
config.BadgerVersion = badgerVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed config/*
|
//go:embed config/*
|
||||||
@@ -82,14 +87,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
|
|
||||||
var config Config
|
var config Config
|
||||||
var alreadyInstalled = false
|
var alreadyInstalled = false
|
||||||
|
|
||||||
// check if there is already a config file
|
// check if there is already a config file
|
||||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||||
config = collectUserInput(reader)
|
config = collectUserInput()
|
||||||
|
|
||||||
loadVersions(&config)
|
loadVersions(&config)
|
||||||
config.DoCrowdsecInstall = false
|
config.DoCrowdsecInstall = false
|
||||||
@@ -102,7 +105,10 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
moveFile("config/docker-compose.yml", "docker-compose.yml")
|
if err := moveFile("config/docker-compose.yml", "docker-compose.yml"); err != nil {
|
||||||
|
fmt.Printf("Error moving docker-compose.yml: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("\nConfiguration files created successfully!")
|
fmt.Println("\nConfiguration files created successfully!")
|
||||||
|
|
||||||
@@ -117,13 +123,17 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("\n=== Starting installation ===")
|
fmt.Println("\n=== Starting installation ===")
|
||||||
|
|
||||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
if readBool("Would you like to install and start the containers?", true) {
|
||||||
|
|
||||||
config.InstallationContainerType = podmanOrDocker(reader)
|
config.InstallationContainerType = podmanOrDocker()
|
||||||
|
|
||||||
if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker {
|
if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker {
|
||||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
if readBool("Docker is not installed. Would you like to install it?", true) {
|
||||||
installDocker()
|
if err := installDocker(); err != nil {
|
||||||
|
fmt.Printf("Error installing Docker: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// try to start docker service but ignore errors
|
// try to start docker service but ignore errors
|
||||||
if err := startDockerService(); err != nil {
|
if err := startDockerService(); err != nil {
|
||||||
fmt.Println("Error starting Docker service:", err)
|
fmt.Println("Error starting Docker service:", err)
|
||||||
@@ -132,7 +142,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
// wait 10 seconds for docker to start checking if docker is running every 2 seconds
|
// wait 10 seconds for docker to start checking if docker is running every 2 seconds
|
||||||
fmt.Println("Waiting for Docker to start...")
|
fmt.Println("Waiting for Docker to start...")
|
||||||
for i := 0; i < 5; i++ {
|
for range 5 {
|
||||||
if isDockerRunning() {
|
if isDockerRunning() {
|
||||||
fmt.Println("Docker is running!")
|
fmt.Println("Docker is running!")
|
||||||
break
|
break
|
||||||
@@ -167,7 +177,7 @@ func main() {
|
|||||||
fmt.Println("\n=== MaxMind Database Update ===")
|
fmt.Println("\n=== MaxMind Database Update ===")
|
||||||
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database found.")
|
fmt.Println("MaxMind GeoLite2 Country database found.")
|
||||||
if readBool(reader, "Would you like to update the MaxMind database to the latest version?", false) {
|
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try updating it manually later if needed.")
|
fmt.Println("You can try updating it manually later if needed.")
|
||||||
@@ -175,7 +185,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
||||||
if readBool(reader, "Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try downloading it manually later if needed.")
|
fmt.Println("You can try downloading it manually later if needed.")
|
||||||
@@ -192,11 +202,11 @@ func main() {
|
|||||||
if !checkIsCrowdsecInstalledInCompose() {
|
if !checkIsCrowdsecInstalledInCompose() {
|
||||||
fmt.Println("\n=== CrowdSec Install ===")
|
fmt.Println("\n=== CrowdSec Install ===")
|
||||||
// check if crowdsec is installed
|
// check if crowdsec is installed
|
||||||
if readBool(reader, "Would you like to install CrowdSec?", false) {
|
if readBool("Would you like to install CrowdSec?", false) {
|
||||||
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
|
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.
|
// BUG: crowdsec installation will be skipped if the user chooses to install on the first installation.
|
||||||
if readBool(reader, "Are you willing to manage CrowdSec?", false) {
|
if readBool("Are you willing to manage CrowdSec?", false) {
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml")
|
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,8 +235,8 @@ func main() {
|
|||||||
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
||||||
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
||||||
|
|
||||||
if !readBool(reader, "Are these values correct?", true) {
|
if !readBool("Are these values correct?", true) {
|
||||||
config = collectUserInput(reader)
|
config = collectUserInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +245,7 @@ func main() {
|
|||||||
if detectedType == Undefined {
|
if detectedType == Undefined {
|
||||||
// If detection fails, prompt the user
|
// If detection fails, prompt the user
|
||||||
fmt.Println("Unable to detect container type from existing installation.")
|
fmt.Println("Unable to detect container type from existing installation.")
|
||||||
config.InstallationContainerType = podmanOrDocker(reader)
|
config.InstallationContainerType = podmanOrDocker()
|
||||||
} else {
|
} else {
|
||||||
config.InstallationContainerType = detectedType
|
config.InstallationContainerType = detectedType
|
||||||
fmt.Printf("Detected container type: %s\n", config.InstallationContainerType)
|
fmt.Printf("Detected container type: %s\n", config.InstallationContainerType)
|
||||||
@@ -277,8 +287,8 @@ func main() {
|
|||||||
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
|
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
func podmanOrDocker() SupportedContainer {
|
||||||
inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker")
|
inputContainer := readString("Would you like to run Pangolin as Docker or Podman containers?", "docker")
|
||||||
|
|
||||||
chosenContainer := Docker
|
chosenContainer := Docker
|
||||||
if strings.EqualFold(inputContainer, "docker") {
|
if strings.EqualFold(inputContainer, "docker") {
|
||||||
@@ -290,7 +300,8 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if chosenContainer == Podman {
|
switch chosenContainer {
|
||||||
|
case Podman:
|
||||||
if !isPodmanInstalled() {
|
if !isPodmanInstalled() {
|
||||||
fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.")
|
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)
|
os.Exit(1)
|
||||||
@@ -299,7 +310,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
|||||||
if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil {
|
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("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.")
|
fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.")
|
||||||
approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true)
|
approved := readBool("The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true)
|
||||||
if approved {
|
if approved {
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
fmt.Println("You need to run the installer as root for such a configuration.")
|
fmt.Println("You need to run the installer as root for such a configuration.")
|
||||||
@@ -321,7 +332,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
|||||||
fmt.Println("Unprivileged ports have been configured.")
|
fmt.Println("Unprivileged ports have been configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if chosenContainer == Docker {
|
case Docker:
|
||||||
// check if docker is not installed and the user is root
|
// check if docker is not installed and the user is root
|
||||||
if !isDockerInstalled() {
|
if !isDockerInstalled() {
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
@@ -336,7 +347,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
|||||||
fmt.Println("The installer will not be able to run docker commands without running it as root.")
|
fmt.Println("The installer will not be able to run docker commands without running it as root.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
} else {
|
default:
|
||||||
// This shouldn't happen unless there's a third container runtime.
|
// This shouldn't happen unless there's a third container runtime.
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -344,35 +355,35 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
|||||||
return chosenContainer
|
return chosenContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectUserInput(reader *bufio.Reader) Config {
|
func collectUserInput() Config {
|
||||||
config := Config{}
|
config := Config{}
|
||||||
|
|
||||||
// Basic configuration
|
// Basic configuration
|
||||||
fmt.Println("\n=== Basic Configuration ===")
|
fmt.Println("\n=== Basic Configuration ===")
|
||||||
|
|
||||||
config.IsEnterprise = readBoolNoDefault(reader, "Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
||||||
|
|
||||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
|
|
||||||
// Set default dashboard domain after base domain is collected
|
// Set default dashboard domain after base domain is collected
|
||||||
defaultDashboardDomain := ""
|
defaultDashboardDomain := ""
|
||||||
if config.BaseDomain != "" {
|
if config.BaseDomain != "" {
|
||||||
defaultDashboardDomain = "pangolin." + config.BaseDomain
|
defaultDashboardDomain = "pangolin." + config.BaseDomain
|
||||||
}
|
}
|
||||||
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
|
config.DashboardDomain = readString("Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
|
||||||
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
config.LetsEncryptEmail = readString("Enter email for Let's Encrypt certificates", "")
|
||||||
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
|
config.InstallGerbil = readBool("Do you want to use Gerbil to allow tunneled connections", true)
|
||||||
|
|
||||||
// Email configuration
|
// Email configuration
|
||||||
fmt.Println("\n=== Email Configuration ===")
|
fmt.Println("\n=== Email Configuration ===")
|
||||||
config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false)
|
config.EnableEmail = readBool("Enable email functionality (SMTP)", false)
|
||||||
|
|
||||||
if config.EnableEmail {
|
if config.EnableEmail {
|
||||||
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
|
config.EmailSMTPHost = readString("Enter SMTP host", "")
|
||||||
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
|
config.EmailSMTPPort = readInt("Enter SMTP port (default 587)", 587)
|
||||||
config.EmailSMTPUser = readString(reader, "Enter SMTP username", "")
|
config.EmailSMTPUser = readString("Enter SMTP username", "")
|
||||||
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword?
|
config.EmailSMTPPass = readPassword("Enter SMTP password")
|
||||||
config.EmailNoReply = readString(reader, "Enter no-reply email address (often the same as SMTP username)", "")
|
config.EmailNoReply = readString("Enter no-reply email address (often the same as SMTP username)", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -393,8 +404,8 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||||||
|
|
||||||
fmt.Println("\n=== Advanced Configuration ===")
|
fmt.Println("\n=== Advanced Configuration ===")
|
||||||
|
|
||||||
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true)
|
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
||||||
config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
|
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
|
||||||
|
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
fmt.Println("Error: Dashboard Domain name is required")
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
@@ -405,10 +416,18 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createConfigFiles(config Config) error {
|
func createConfigFiles(config Config) error {
|
||||||
os.MkdirAll("config", 0755)
|
if err := os.MkdirAll("config", 0755); err != nil {
|
||||||
os.MkdirAll("config/letsencrypt", 0755)
|
return fmt.Errorf("failed to create config directory: %v", err)
|
||||||
os.MkdirAll("config/db", 0755)
|
}
|
||||||
os.MkdirAll("config/logs", 0755)
|
if err := os.MkdirAll("config/letsencrypt", 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create letsencrypt directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("config/db", 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create db directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("config/logs", 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create logs directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Walk through all embedded files
|
// Walk through all embedded files
|
||||||
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
||||||
@@ -562,22 +581,24 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
|
|||||||
fmt.Println("To get your setup token, you need to:")
|
fmt.Println("To get your setup token, you need to:")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("1. Start the containers")
|
fmt.Println("1. Start the containers")
|
||||||
if containerType == Docker {
|
switch containerType {
|
||||||
|
case Docker:
|
||||||
fmt.Println(" docker compose up -d")
|
fmt.Println(" docker compose up -d")
|
||||||
} else if containerType == Podman {
|
case Podman:
|
||||||
fmt.Println(" podman-compose up -d")
|
fmt.Println(" podman-compose up -d")
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("2. Wait for the Pangolin container to start and generate the token")
|
fmt.Println("2. Wait for the Pangolin container to start and generate the token")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("3. Check the container logs for the setup token")
|
fmt.Println("3. Check the container logs for the setup token")
|
||||||
if containerType == Docker {
|
switch containerType {
|
||||||
|
case Docker:
|
||||||
fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
||||||
} else if containerType == Podman {
|
case Podman:
|
||||||
fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("4. Look for output like")
|
fmt.Println("4. Look for output like")
|
||||||
fmt.Println(" === SETUP TOKEN GENERATED ===")
|
fmt.Println(" === SETUP TOKEN GENERATED ===")
|
||||||
@@ -639,10 +660,7 @@ func checkPortsAvailable(port int) error {
|
|||||||
addr := fmt.Sprintf(":%d", port)
|
addr := fmt.Sprintf(":%d", port)
|
||||||
ln, err := net.Listen("tcp", addr)
|
ln, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf("ERROR: port %d is occupied or cannot be bound: %w", port, err)
|
||||||
"ERROR: port %d is occupied or cannot be bound: %w\n\n",
|
|
||||||
port, err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if closeErr := ln.Close(); closeErr != nil {
|
if closeErr := ln.Close(); closeErr != nil {
|
||||||
fmt.Fprintf(os.Stderr,
|
fmt.Fprintf(os.Stderr,
|
||||||
|
|||||||
51
install/theme.go
Normal file
51
install/theme.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pangolin brand colors (converted from oklch to hex)
|
||||||
|
var (
|
||||||
|
// Primary orange/amber - oklch(0.6717 0.1946 41.93)
|
||||||
|
primaryColor = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"}
|
||||||
|
// Muted foreground
|
||||||
|
mutedColor = lipgloss.AdaptiveColor{Light: "#737373", Dark: "#A3A3A3"}
|
||||||
|
// Success green
|
||||||
|
successColor = lipgloss.AdaptiveColor{Light: "#16A34A", Dark: "#22C55E"}
|
||||||
|
// Error red - oklch(0.577 0.245 27.325)
|
||||||
|
errorColor = lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#EF4444"}
|
||||||
|
// Normal text
|
||||||
|
normalFg = lipgloss.AdaptiveColor{Light: "#171717", Dark: "#FAFAFA"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ThemePangolin returns a huh theme using Pangolin brand colors
|
||||||
|
func ThemePangolin() *huh.Theme {
|
||||||
|
t := huh.ThemeBase()
|
||||||
|
|
||||||
|
// Focused state styles
|
||||||
|
t.Focused.Base = t.Focused.Base.BorderForeground(primaryColor)
|
||||||
|
t.Focused.Title = t.Focused.Title.Foreground(primaryColor).Bold(true)
|
||||||
|
t.Focused.Description = t.Focused.Description.Foreground(mutedColor)
|
||||||
|
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errorColor)
|
||||||
|
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errorColor)
|
||||||
|
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(primaryColor)
|
||||||
|
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(primaryColor)
|
||||||
|
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(primaryColor)
|
||||||
|
t.Focused.Option = t.Focused.Option.Foreground(normalFg)
|
||||||
|
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(primaryColor)
|
||||||
|
t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(successColor).SetString("✓ ")
|
||||||
|
t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(mutedColor).SetString(" ")
|
||||||
|
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(primaryColor)
|
||||||
|
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#404040"})
|
||||||
|
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primaryColor)
|
||||||
|
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(primaryColor)
|
||||||
|
|
||||||
|
// Blurred state inherits from focused but with hidden border
|
||||||
|
t.Blurred = t.Focused
|
||||||
|
t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||||
|
t.Blurred.Title = t.Blurred.Title.Foreground(mutedColor).Bold(false)
|
||||||
|
t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(mutedColor)
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Неуспешно превключване на ресурса",
|
"resourcesErrorUpdate": "Неуспешно превключване на ресурса",
|
||||||
"resourcesErrorUpdateDescription": "Възникна грешка при актуализиране на ресурса",
|
"resourcesErrorUpdateDescription": "Възникна грешка при актуализиране на ресурса",
|
||||||
"access": "Достъп",
|
"access": "Достъп",
|
||||||
|
"accessControl": "Контрол на достъпа",
|
||||||
"shareLink": "{resource} Сподели връзка",
|
"shareLink": "{resource} Сподели връзка",
|
||||||
"resourceSelect": "Изберете ресурс",
|
"resourceSelect": "Изберете ресурс",
|
||||||
"shareLinks": "Споделени връзки",
|
"shareLinks": "Споделени връзки",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "О, не! Страницата, която търсите, не съществува.",
|
"pageNotFoundDescription": "О, не! Страницата, която търсите, не съществува.",
|
||||||
"overview": "Общ преглед",
|
"overview": "Общ преглед",
|
||||||
"home": "Начало",
|
"home": "Начало",
|
||||||
"accessControl": "Контрол на достъпа",
|
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"usersAll": "Всички потребители",
|
"usersAll": "Всички потребители",
|
||||||
"license": "Лиценз",
|
"license": "Лиценз",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Nepodařilo se přepnout zdroj",
|
"resourcesErrorUpdate": "Nepodařilo se přepnout zdroj",
|
||||||
"resourcesErrorUpdateDescription": "Došlo k chybě při aktualizaci zdroje",
|
"resourcesErrorUpdateDescription": "Došlo k chybě při aktualizaci zdroje",
|
||||||
"access": "Přístup",
|
"access": "Přístup",
|
||||||
|
"accessControl": "Kontrola přístupu",
|
||||||
"shareLink": "{resource} Sdílet odkaz",
|
"shareLink": "{resource} Sdílet odkaz",
|
||||||
"resourceSelect": "Vyberte zdroj",
|
"resourceSelect": "Vyberte zdroj",
|
||||||
"shareLinks": "Sdílet odkazy",
|
"shareLinks": "Sdílet odkazy",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Jejda! Stránka, kterou hledáte, neexistuje.",
|
"pageNotFoundDescription": "Jejda! Stránka, kterou hledáte, neexistuje.",
|
||||||
"overview": "Přehled",
|
"overview": "Přehled",
|
||||||
"home": "Domů",
|
"home": "Domů",
|
||||||
"accessControl": "Kontrola přístupu",
|
|
||||||
"settings": "Nastavení",
|
"settings": "Nastavení",
|
||||||
"usersAll": "Všichni uživatelé",
|
"usersAll": "Všichni uživatelé",
|
||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Fehler beim Umschalten der Ressource",
|
"resourcesErrorUpdate": "Fehler beim Umschalten der Ressource",
|
||||||
"resourcesErrorUpdateDescription": "Beim Aktualisieren der Ressource ist ein Fehler aufgetreten",
|
"resourcesErrorUpdateDescription": "Beim Aktualisieren der Ressource ist ein Fehler aufgetreten",
|
||||||
"access": "Zugriff",
|
"access": "Zugriff",
|
||||||
|
"accessControl": "Zugriffskontrolle",
|
||||||
"shareLink": "{resource} Freigabe-Link",
|
"shareLink": "{resource} Freigabe-Link",
|
||||||
"resourceSelect": "Ressource auswählen",
|
"resourceSelect": "Ressource auswählen",
|
||||||
"shareLinks": "Freigabe-Links",
|
"shareLinks": "Freigabe-Links",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Hoppla! Die gesuchte Seite existiert nicht.",
|
"pageNotFoundDescription": "Hoppla! Die gesuchte Seite existiert nicht.",
|
||||||
"overview": "Übersicht",
|
"overview": "Übersicht",
|
||||||
"home": "Startseite",
|
"home": "Startseite",
|
||||||
"accessControl": "Zugriffskontrolle",
|
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"usersAll": "Alle Benutzer",
|
"usersAll": "Alle Benutzer",
|
||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
|
|||||||
@@ -649,7 +649,8 @@
|
|||||||
"resourcesUsersRolesAccess": "User and role-based access control",
|
"resourcesUsersRolesAccess": "User and role-based access control",
|
||||||
"resourcesErrorUpdate": "Failed to toggle resource",
|
"resourcesErrorUpdate": "Failed to toggle resource",
|
||||||
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
|
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
|
||||||
"access": "Access Control",
|
"access": "Access",
|
||||||
|
"accessControl": "Access Control",
|
||||||
"shareLink": "{resource} Share Link",
|
"shareLink": "{resource} Share Link",
|
||||||
"resourceSelect": "Select resource",
|
"resourceSelect": "Select resource",
|
||||||
"shareLinks": "Share Links",
|
"shareLinks": "Share Links",
|
||||||
@@ -2542,7 +2543,7 @@
|
|||||||
"internalResourceAuthDaemonSite": "On Site",
|
"internalResourceAuthDaemonSite": "On Site",
|
||||||
"internalResourceAuthDaemonSiteDescription": "Auth daemon runs on the site (Newt).",
|
"internalResourceAuthDaemonSiteDescription": "Auth daemon runs on the site (Newt).",
|
||||||
"internalResourceAuthDaemonRemote": "Remote Host",
|
"internalResourceAuthDaemonRemote": "Remote Host",
|
||||||
"internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on a host that is not the site.",
|
"internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on this resource's destination - not the site.",
|
||||||
"internalResourceAuthDaemonPort": "Daemon Port (optional)",
|
"internalResourceAuthDaemonPort": "Daemon Port (optional)",
|
||||||
"orgAuthWhatsThis": "Where can I find my organization ID?",
|
"orgAuthWhatsThis": "Where can I find my organization ID?",
|
||||||
"learnMore": "Learn more",
|
"learnMore": "Learn more",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Error al cambiar el recurso",
|
"resourcesErrorUpdate": "Error al cambiar el recurso",
|
||||||
"resourcesErrorUpdateDescription": "Se ha producido un error al actualizar el recurso",
|
"resourcesErrorUpdateDescription": "Se ha producido un error al actualizar el recurso",
|
||||||
"access": "Acceder",
|
"access": "Acceder",
|
||||||
|
"accessControl": "Control de acceso",
|
||||||
"shareLink": "{resource} Compartir Enlace",
|
"shareLink": "{resource} Compartir Enlace",
|
||||||
"resourceSelect": "Seleccionar recurso",
|
"resourceSelect": "Seleccionar recurso",
|
||||||
"shareLinks": "Compartir enlaces",
|
"shareLinks": "Compartir enlaces",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "¡Vaya! La página que estás buscando no existe.",
|
"pageNotFoundDescription": "¡Vaya! La página que estás buscando no existe.",
|
||||||
"overview": "Resumen",
|
"overview": "Resumen",
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"accessControl": "Control de acceso",
|
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"usersAll": "Todos los usuarios",
|
"usersAll": "Todos los usuarios",
|
||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Échec de la bascule de la ressource",
|
"resourcesErrorUpdate": "Échec de la bascule de la ressource",
|
||||||
"resourcesErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour de la ressource",
|
"resourcesErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour de la ressource",
|
||||||
"access": "Accès",
|
"access": "Accès",
|
||||||
|
"accessControl": "Contrôle d'accès",
|
||||||
"shareLink": "Lien de partage {resource}",
|
"shareLink": "Lien de partage {resource}",
|
||||||
"resourceSelect": "Sélectionner une ressource",
|
"resourceSelect": "Sélectionner une ressource",
|
||||||
"shareLinks": "Liens de partage",
|
"shareLinks": "Liens de partage",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Oups! La page que vous recherchez n'existe pas.",
|
"pageNotFoundDescription": "Oups! La page que vous recherchez n'existe pas.",
|
||||||
"overview": "Vue d'ensemble",
|
"overview": "Vue d'ensemble",
|
||||||
"home": "Accueil",
|
"home": "Accueil",
|
||||||
"accessControl": "Contrôle d'accès",
|
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"usersAll": "Tous les utilisateurs",
|
"usersAll": "Tous les utilisateurs",
|
||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Impossibile attivare/disattivare la risorsa",
|
"resourcesErrorUpdate": "Impossibile attivare/disattivare la risorsa",
|
||||||
"resourcesErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento della risorsa",
|
"resourcesErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento della risorsa",
|
||||||
"access": "Accesso",
|
"access": "Accesso",
|
||||||
|
"accessControl": "Controllo Accessi",
|
||||||
"shareLink": "Link di Condivisione {resource}",
|
"shareLink": "Link di Condivisione {resource}",
|
||||||
"resourceSelect": "Seleziona risorsa",
|
"resourceSelect": "Seleziona risorsa",
|
||||||
"shareLinks": "Link di Condivisione",
|
"shareLinks": "Link di Condivisione",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Oops! La pagina che stai cercando non esiste.",
|
"pageNotFoundDescription": "Oops! La pagina che stai cercando non esiste.",
|
||||||
"overview": "Panoramica",
|
"overview": "Panoramica",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"accessControl": "Controllo Accessi",
|
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
"usersAll": "Tutti Gli Utenti",
|
"usersAll": "Tutti Gli Utenti",
|
||||||
"license": "Licenza",
|
"license": "Licenza",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "리소스를 전환하는 데 실패했습니다.",
|
"resourcesErrorUpdate": "리소스를 전환하는 데 실패했습니다.",
|
||||||
"resourcesErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.",
|
"resourcesErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.",
|
||||||
"access": "접속",
|
"access": "접속",
|
||||||
|
"accessControl": "액세스 제어",
|
||||||
"shareLink": "{resource} 공유 링크",
|
"shareLink": "{resource} 공유 링크",
|
||||||
"resourceSelect": "리소스 선택",
|
"resourceSelect": "리소스 선택",
|
||||||
"shareLinks": "공유 링크",
|
"shareLinks": "공유 링크",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "앗! 찾고 있는 페이지가 존재하지 않습니다.",
|
"pageNotFoundDescription": "앗! 찾고 있는 페이지가 존재하지 않습니다.",
|
||||||
"overview": "개요",
|
"overview": "개요",
|
||||||
"home": "홈",
|
"home": "홈",
|
||||||
"accessControl": "액세스 제어",
|
|
||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"usersAll": "모든 사용자",
|
"usersAll": "모든 사용자",
|
||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Feilet å slå av/på ressurs",
|
"resourcesErrorUpdate": "Feilet å slå av/på ressurs",
|
||||||
"resourcesErrorUpdateDescription": "En feil oppstod under oppdatering av ressursen",
|
"resourcesErrorUpdateDescription": "En feil oppstod under oppdatering av ressursen",
|
||||||
"access": "Tilgang",
|
"access": "Tilgang",
|
||||||
|
"accessControl": "Tilgangskontroll",
|
||||||
"shareLink": "{resource} Del Lenke",
|
"shareLink": "{resource} Del Lenke",
|
||||||
"resourceSelect": "Velg ressurs",
|
"resourceSelect": "Velg ressurs",
|
||||||
"shareLinks": "Del lenker",
|
"shareLinks": "Del lenker",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Oops! Siden du leter etter finnes ikke.",
|
"pageNotFoundDescription": "Oops! Siden du leter etter finnes ikke.",
|
||||||
"overview": "Oversikt",
|
"overview": "Oversikt",
|
||||||
"home": "Hjem",
|
"home": "Hjem",
|
||||||
"accessControl": "Tilgangskontroll",
|
|
||||||
"settings": "Innstillinger",
|
"settings": "Innstillinger",
|
||||||
"usersAll": "Alle brukere",
|
"usersAll": "Alle brukere",
|
||||||
"license": "Lisens",
|
"license": "Lisens",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Bron wisselen mislukt",
|
"resourcesErrorUpdate": "Bron wisselen mislukt",
|
||||||
"resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document",
|
"resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document",
|
||||||
"access": "Toegangsrechten",
|
"access": "Toegangsrechten",
|
||||||
|
"accessControl": "Toegangs controle",
|
||||||
"shareLink": "{resource} Share link",
|
"shareLink": "{resource} Share link",
|
||||||
"resourceSelect": "Selecteer resource",
|
"resourceSelect": "Selecteer resource",
|
||||||
"shareLinks": "Links delen",
|
"shareLinks": "Links delen",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Oeps! De pagina die je zoekt bestaat niet.",
|
"pageNotFoundDescription": "Oeps! De pagina die je zoekt bestaat niet.",
|
||||||
"overview": "Overzicht.",
|
"overview": "Overzicht.",
|
||||||
"home": "Startpagina",
|
"home": "Startpagina",
|
||||||
"accessControl": "Toegangs controle",
|
|
||||||
"settings": "Instellingen",
|
"settings": "Instellingen",
|
||||||
"usersAll": "Alle gebruikers",
|
"usersAll": "Alle gebruikers",
|
||||||
"license": "Licentie",
|
"license": "Licentie",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Nie udało się przełączyć zasobu",
|
"resourcesErrorUpdate": "Nie udało się przełączyć zasobu",
|
||||||
"resourcesErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji zasobu",
|
"resourcesErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji zasobu",
|
||||||
"access": "Dostęp",
|
"access": "Dostęp",
|
||||||
|
"accessControl": "Kontrola dostępu",
|
||||||
"shareLink": "Link udostępniania {resource}",
|
"shareLink": "Link udostępniania {resource}",
|
||||||
"resourceSelect": "Wybierz zasób",
|
"resourceSelect": "Wybierz zasób",
|
||||||
"shareLinks": "Linki udostępniania",
|
"shareLinks": "Linki udostępniania",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Ups! Strona, której szukasz, nie istnieje.",
|
"pageNotFoundDescription": "Ups! Strona, której szukasz, nie istnieje.",
|
||||||
"overview": "Przegląd",
|
"overview": "Przegląd",
|
||||||
"home": "Strona główna",
|
"home": "Strona główna",
|
||||||
"accessControl": "Kontrola dostępu",
|
|
||||||
"settings": "Ustawienia",
|
"settings": "Ustawienia",
|
||||||
"usersAll": "Wszyscy użytkownicy",
|
"usersAll": "Wszyscy użytkownicy",
|
||||||
"license": "Licencja",
|
"license": "Licencja",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Falha ao alternar recurso",
|
"resourcesErrorUpdate": "Falha ao alternar recurso",
|
||||||
"resourcesErrorUpdateDescription": "Ocorreu um erro ao atualizar o recurso",
|
"resourcesErrorUpdateDescription": "Ocorreu um erro ao atualizar o recurso",
|
||||||
"access": "Acesso",
|
"access": "Acesso",
|
||||||
|
"accessControl": "Controle de Acesso",
|
||||||
"shareLink": "Link de Compartilhamento {resource}",
|
"shareLink": "Link de Compartilhamento {resource}",
|
||||||
"resourceSelect": "Selecionar recurso",
|
"resourceSelect": "Selecionar recurso",
|
||||||
"shareLinks": "Links de Compartilhamento",
|
"shareLinks": "Links de Compartilhamento",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Ops! A página que você está procurando não existe.",
|
"pageNotFoundDescription": "Ops! A página que você está procurando não existe.",
|
||||||
"overview": "Visão Geral",
|
"overview": "Visão Geral",
|
||||||
"home": "Início",
|
"home": "Início",
|
||||||
"accessControl": "Controle de Acesso",
|
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"usersAll": "Todos os Utilizadores",
|
"usersAll": "Todos os Utilizadores",
|
||||||
"license": "Licença",
|
"license": "Licença",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Не удалось переключить ресурс",
|
"resourcesErrorUpdate": "Не удалось переключить ресурс",
|
||||||
"resourcesErrorUpdateDescription": "Произошла ошибка при обновлении ресурса",
|
"resourcesErrorUpdateDescription": "Произошла ошибка при обновлении ресурса",
|
||||||
"access": "Доступ",
|
"access": "Доступ",
|
||||||
|
"accessControl": "Контроль доступа",
|
||||||
"shareLink": "Общая ссылка {resource}",
|
"shareLink": "Общая ссылка {resource}",
|
||||||
"resourceSelect": "Выберите ресурс",
|
"resourceSelect": "Выберите ресурс",
|
||||||
"shareLinks": "Общие ссылки",
|
"shareLinks": "Общие ссылки",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Упс! Страница, которую вы ищете, не существует.",
|
"pageNotFoundDescription": "Упс! Страница, которую вы ищете, не существует.",
|
||||||
"overview": "Обзор",
|
"overview": "Обзор",
|
||||||
"home": "Главная",
|
"home": "Главная",
|
||||||
"accessControl": "Контроль доступа",
|
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"usersAll": "Все пользователи",
|
"usersAll": "Все пользователи",
|
||||||
"license": "Лицензия",
|
"license": "Лицензия",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "Kaynak değiştirilemedi",
|
"resourcesErrorUpdate": "Kaynak değiştirilemedi",
|
||||||
"resourcesErrorUpdateDescription": "Kaynak güncellenirken bir hata oluştu",
|
"resourcesErrorUpdateDescription": "Kaynak güncellenirken bir hata oluştu",
|
||||||
"access": "Erişim",
|
"access": "Erişim",
|
||||||
|
"accessControl": "Erişim Kontrolü",
|
||||||
"shareLink": "{resource} Paylaşım Bağlantısı",
|
"shareLink": "{resource} Paylaşım Bağlantısı",
|
||||||
"resourceSelect": "Kaynak seçin",
|
"resourceSelect": "Kaynak seçin",
|
||||||
"shareLinks": "Paylaşım Bağlantıları",
|
"shareLinks": "Paylaşım Bağlantıları",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "Oops! Aradığınız sayfa mevcut değil.",
|
"pageNotFoundDescription": "Oops! Aradığınız sayfa mevcut değil.",
|
||||||
"overview": "Genel Bakış",
|
"overview": "Genel Bakış",
|
||||||
"home": "Ana Sayfa",
|
"home": "Ana Sayfa",
|
||||||
"accessControl": "Erişim Kontrolü",
|
|
||||||
"settings": "Ayarlar",
|
"settings": "Ayarlar",
|
||||||
"usersAll": "Tüm Kullanıcılar",
|
"usersAll": "Tüm Kullanıcılar",
|
||||||
"license": "Lisans",
|
"license": "Lisans",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"resourcesErrorUpdate": "切换资源失败",
|
"resourcesErrorUpdate": "切换资源失败",
|
||||||
"resourcesErrorUpdateDescription": "更新资源时出错",
|
"resourcesErrorUpdateDescription": "更新资源时出错",
|
||||||
"access": "访问权限",
|
"access": "访问权限",
|
||||||
|
"accessControl": "访问控制",
|
||||||
"shareLink": "{resource} 的分享链接",
|
"shareLink": "{resource} 的分享链接",
|
||||||
"resourceSelect": "选择资源",
|
"resourceSelect": "选择资源",
|
||||||
"shareLinks": "分享链接",
|
"shareLinks": "分享链接",
|
||||||
@@ -1038,7 +1039,6 @@
|
|||||||
"pageNotFoundDescription": "哎呀!您正在查找的页面不存在。",
|
"pageNotFoundDescription": "哎呀!您正在查找的页面不存在。",
|
||||||
"overview": "概览",
|
"overview": "概览",
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
"accessControl": "访问控制",
|
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"usersAll": "所有用户",
|
"usersAll": "所有用户",
|
||||||
"license": "许可协议",
|
"license": "许可协议",
|
||||||
|
|||||||
511
package-lock.json
generated
511
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -32,7 +32,7 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.0",
|
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||||
"@aws-sdk/client-s3": "3.989.0",
|
"@aws-sdk/client-s3": "3.989.0",
|
||||||
"@faker-js/faker": "10.3.0",
|
"@faker-js/faker": "10.3.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
@@ -59,11 +59,11 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.7",
|
"@react-email/components": "1.0.8",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/render": "2.0.4",
|
||||||
"@react-email/tailwind": "2.0.4",
|
"@react-email/tailwind": "2.0.5",
|
||||||
"@simplewebauthn/browser": "13.2.2",
|
"@simplewebauthn/browser": "13.2.2",
|
||||||
"@simplewebauthn/server": "13.2.2",
|
"@simplewebauthn/server": "13.2.3",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.2.1",
|
"express-rate-limit": "8.2.1",
|
||||||
"glob": "13.0.3",
|
"glob": "13.0.6",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
@@ -93,20 +93,20 @@
|
|||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.5",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.12",
|
"next": "15.5.12",
|
||||||
"next-intl": "4.8.2",
|
"next-intl": "4.8.3",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.1",
|
"nodemailer": "8.0.1",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.18.0",
|
"pg": "8.19.0",
|
||||||
"posthog-node": "5.24.15",
|
"posthog-node": "5.26.0",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "9.13.2",
|
"react-day-picker": "9.13.2",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.1",
|
"react-hook-form": "7.71.2",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.5.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"reodotdev": "1.0.0",
|
"reodotdev": "1.0.0",
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
"sshpk": "^1.18.0",
|
"sshpk": "^1.18.0",
|
||||||
"stripe": "20.3.1",
|
"stripe": "20.3.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"use-debounce": "^10.1.0",
|
"use-debounce": "^10.1.0",
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.2.3",
|
"@types/node": "25.2.3",
|
||||||
"@types/nodemailer": "7.0.9",
|
"@types/nodemailer": "7.0.11",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.16.0",
|
"@types/pg": "8.16.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export async function validateResourceSessionToken(
|
|||||||
if (Date.now() >= resourceSession.expiresAt) {
|
if (Date.now() >= resourceSession.expiresAt) {
|
||||||
await db
|
await db
|
||||||
.delete(resourceSessions)
|
.delete(resourceSessions)
|
||||||
.where(eq(resourceSessions.sessionId, resourceSessions.sessionId));
|
.where(eq(resourceSessions.sessionId, sessionId));
|
||||||
return { resourceSession: null };
|
return { resourceSession: null };
|
||||||
} else if (
|
} else if (
|
||||||
Date.now() >=
|
Date.now() >=
|
||||||
@@ -181,7 +181,7 @@ export function serializeResourceSessionCookie(
|
|||||||
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${domain}`;
|
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${domain}`;
|
||||||
} else {
|
} else {
|
||||||
if (expiresAt === undefined) {
|
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}`;
|
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${domain}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||||
import { readPrivateConfigFile } from "@server/private/lib/readConfigFile";
|
|
||||||
import { withReplicas } from "drizzle-orm/pg-core";
|
import { withReplicas } from "drizzle-orm/pg-core";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
||||||
@@ -13,10 +12,9 @@ function createLogsDb() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = readConfigFile();
|
const config = readConfigFile();
|
||||||
const privateConfig = readPrivateConfigFile();
|
|
||||||
|
|
||||||
// Merge configs, prioritizing private config
|
// Merge configs, prioritizing private config
|
||||||
const logsConfig = privateConfig.postgres_logs || config.postgres_logs;
|
const logsConfig = config.postgres_logs;
|
||||||
|
|
||||||
// Check environment variable first
|
// Check environment variable first
|
||||||
let connectionString = process.env.POSTGRES_LOGS_CONNECTION_STRING;
|
let connectionString = process.env.POSTGRES_LOGS_CONNECTION_STRING;
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const internalPort = config.getRawConfig().server.internal_port;
|
|||||||
export function createInternalServer() {
|
export function createInternalServer() {
|
||||||
const internalServer = express();
|
const internalServer = express();
|
||||||
|
|
||||||
|
const trustProxy = config.getRawConfig().server.trust_proxy;
|
||||||
|
if (trustProxy) {
|
||||||
|
internalServer.set("trust proxy", trustProxy);
|
||||||
|
}
|
||||||
|
|
||||||
internalServer.use(helmet());
|
internalServer.use(helmet());
|
||||||
internalServer.use(cors());
|
internalServer.use(cors());
|
||||||
internalServer.use(stripDuplicateSesions);
|
internalServer.use(stripDuplicateSesions);
|
||||||
|
|||||||
@@ -48,5 +48,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
"enterprise"
|
"enterprise"
|
||||||
],
|
],
|
||||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
||||||
[TierFeature.SshPam]: ["enterprise"]
|
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { FeatureId, getFeatureMeterId } from "./features";
|
import { FeatureId, getFeatureMeterId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
|
|
||||||
export function noop() {
|
export function noop() {
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
@@ -230,7 +230,7 @@ export class UsageService {
|
|||||||
const orgIdToUse = await this.getBillingOrg(orgId);
|
const orgIdToUse = await this.getBillingOrg(orgId);
|
||||||
|
|
||||||
const cacheKey = `customer_${orgIdToUse}_${featureId}`;
|
const cacheKey = `customer_${orgIdToUse}_${featureId}`;
|
||||||
const cached = cache.get<string>(cacheKey);
|
const cached = await cache.get<string>(cacheKey);
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
@@ -253,7 +253,7 @@ export class UsageService {
|
|||||||
const customerId = customer.customerId;
|
const customerId = customer.customerId;
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
cache.set(cacheKey, customerId, 300); // 5 minute TTL
|
await cache.set(cacheKey, customerId, 300); // 5 minute TTL
|
||||||
|
|
||||||
return customerId;
|
return customerId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
// Create cache with maxKeys limit to prevent memory leaks
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
export const cache = new NodeCache({
|
export const localCache = new NodeCache({
|
||||||
stdTTL: 3600,
|
stdTTL: 3600,
|
||||||
checkperiod: 120,
|
checkperiod: 120,
|
||||||
maxKeys: 10000
|
maxKeys: 10000
|
||||||
@@ -11,10 +11,151 @@ export const cache = new NodeCache({
|
|||||||
|
|
||||||
// Log cache statistics periodically for monitoring
|
// Log cache statistics periodically for monitoring
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const stats = cache.getStats();
|
const stats = localCache.getStats();
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`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}%`
|
`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
|
}, 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;
|
export default cache;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.15.4";
|
export const APP_VERSION = "1.16.0";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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";
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
266
server/private/lib/cache.ts
Normal file
266
server/private/lib/cache.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
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;
|
||||||
@@ -15,9 +15,8 @@ import config from "./config";
|
|||||||
import { certificates, db } from "@server/db";
|
import { certificates, db } from "@server/db";
|
||||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||||
import { decryptData } from "@server/lib/encryption";
|
import { decryptData } from "@server/lib/encryption";
|
||||||
import * as fs from "fs";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "#private/lib/cache";
|
||||||
|
|
||||||
let encryptionKeyHex = "";
|
let encryptionKeyHex = "";
|
||||||
let encryptionKey: Buffer;
|
let encryptionKey: Buffer;
|
||||||
@@ -55,7 +54,7 @@ export async function getValidCertificatesForDomains(
|
|||||||
if (useCache) {
|
if (useCache) {
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const cacheKey = `cert:${domain}`;
|
const cacheKey = `cert:${domain}`;
|
||||||
const cachedCert = cache.get<CertificateResult>(cacheKey);
|
const cachedCert = await cache.get<CertificateResult>(cacheKey);
|
||||||
if (cachedCert) {
|
if (cachedCert) {
|
||||||
finalResults.push(cachedCert); // Valid cache hit
|
finalResults.push(cachedCert); // Valid cache hit
|
||||||
} else {
|
} else {
|
||||||
@@ -169,7 +168,7 @@ export async function getValidCertificatesForDomains(
|
|||||||
// Add to cache for future requests, using the *requested domain* as the key
|
// Add to cache for future requests, using the *requested domain* as the key
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
const cacheKey = `cert:${domain}`;
|
const cacheKey = `cert:${domain}`;
|
||||||
cache.set(cacheKey, resultCert, 180);
|
await cache.set(cacheKey, resultCert, 180);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import { accessAuditLog, logsDb, db, orgs } from "@server/db";
|
|||||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { and, eq, lt } from "drizzle-orm";
|
import { and, eq, lt } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "#private/lib/cache";
|
||||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
import { stripPortFromHost } from "@server/lib/ip";
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
|
|
||||||
async function getAccessDays(orgId: string): Promise<number> {
|
async function getAccessDays(orgId: string): Promise<number> {
|
||||||
// check cache first
|
// check cache first
|
||||||
const cached = cache.get<number>(`org_${orgId}_accessDays`);
|
const cached = await cache.get<number>(`org_${orgId}_accessDays`);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ async function getAccessDays(orgId: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// store the result in cache
|
// store the result in cache
|
||||||
cache.set(
|
await cache.set(
|
||||||
`org_${orgId}_accessDays`,
|
`org_${orgId}_accessDays`,
|
||||||
org.settingsLogRetentionDaysAction,
|
org.settingsLogRetentionDaysAction,
|
||||||
300
|
300
|
||||||
@@ -146,14 +146,14 @@ export async function logAccessAudit(data: {
|
|||||||
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
|
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
|
||||||
const geoIpCacheKey = `geoip_access:${ip}`;
|
const geoIpCacheKey = `geoip_access:${ip}`;
|
||||||
|
|
||||||
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
|
let cachedCountryCode: string | undefined = await cache.get(geoIpCacheKey);
|
||||||
|
|
||||||
if (!cachedCountryCode) {
|
if (!cachedCountryCode) {
|
||||||
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
|
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
|
||||||
// Only cache successful lookups to avoid filling cache with undefined values
|
// Only cache successful lookups to avoid filling cache with undefined values
|
||||||
if (cachedCountryCode) {
|
if (cachedCountryCode) {
|
||||||
// Cache for longer since IP geolocation doesn't change frequently
|
// Cache for longer since IP geolocation doesn't change frequently
|
||||||
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
|
await cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,46 +83,6 @@ export const privateConfigSchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.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(),
|
|
||||||
gerbil: z
|
gerbil: z
|
||||||
.object({
|
.object({
|
||||||
local_exit_node_reachable_at: z
|
local_exit_node_reachable_at: z
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { and, eq, lt } from "drizzle-orm";
|
import { and, eq, lt } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "#private/lib/cache";
|
||||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
|
|
||||||
async function getActionDays(orgId: string): Promise<number> {
|
async function getActionDays(orgId: string): Promise<number> {
|
||||||
// check cache first
|
// check cache first
|
||||||
const cached = cache.get<number>(`org_${orgId}_actionDays`);
|
const cached = await cache.get<number>(`org_${orgId}_actionDays`);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ async function getActionDays(orgId: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// store the result in cache
|
// store the result in cache
|
||||||
cache.set(
|
await cache.set(
|
||||||
`org_${orgId}_actionDays`,
|
`org_${orgId}_actionDays`,
|
||||||
org.settingsLogRetentionDaysAction,
|
org.settingsLogRetentionDaysAction,
|
||||||
300
|
300
|
||||||
|
|||||||
@@ -480,9 +480,9 @@ authenticated.get(
|
|||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/re-key/:clientId/regenerate-client-secret",
|
"/re-key/:clientId/regenerate-client-secret",
|
||||||
|
verifyClientAccess, // this is first to set the org id
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||||
verifyClientAccess, // this is first to set the org id
|
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||||
reKey.reGenerateClientSecret
|
reKey.reGenerateClientSecret
|
||||||
@@ -490,9 +490,9 @@ authenticated.post(
|
|||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/re-key/:siteId/regenerate-site-secret",
|
"/re-key/:siteId/regenerate-site-secret",
|
||||||
|
verifySiteAccess, // this is first to set the org id
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||||
verifySiteAccess, // this is first to set the org id
|
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||||
reKey.reGenerateSiteSecret
|
reKey.reGenerateSiteSecret
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { eq, or, and } from "drizzle-orm";
|
import { eq, or, and } from "drizzle-orm";
|
||||||
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||||
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
|
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { sendToClient } from "#private/routers/ws";
|
import { sendToClient } from "#private/routers/ws";
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ export async function signSshKey(
|
|||||||
} else if (req.user?.username) {
|
} else if (req.user?.username) {
|
||||||
usernameToUse = 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
|
// 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) {
|
if (!usernameToUse) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -194,6 +194,9 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prefix with p-
|
||||||
|
usernameToUse = `p-${usernameToUse}`;
|
||||||
|
|
||||||
// check if we have a existing user in this org with the same
|
// check if we have a existing user in this org with the same
|
||||||
const [existingUserWithSameName] = await db
|
const [existingUserWithSameName] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -239,6 +242,16 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(userOrgs)
|
||||||
|
.set({ pamUsername: usernameToUse })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
usernameToUse = userOrg.pamUsername;
|
usernameToUse = userOrg.pamUsername;
|
||||||
}
|
}
|
||||||
@@ -310,6 +323,15 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource.mode == "cidr") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"SSHing is not supported for CIDR resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the user has access to the resource
|
// Check if the user has access to the resource
|
||||||
const hasAccess = await canUserAccessSiteResource({
|
const hasAccess = await canUserAccessSiteResource({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { logsDb, primaryLogsDb, db, orgs, requestAuditLog } from "@server/db";
|
import { logsDb, primaryLogsDb, db, orgs, requestAuditLog } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { and, eq, lt, sql } from "drizzle-orm";
|
import { and, eq, lt, sql } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
import { stripPortFromHost } from "@server/lib/ip";
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ export async function shutdownAuditLogger() {
|
|||||||
|
|
||||||
async function getRetentionDays(orgId: string): Promise<number> {
|
async function getRetentionDays(orgId: string): Promise<number> {
|
||||||
// check cache first
|
// check cache first
|
||||||
const cached = cache.get<number>(`org_${orgId}_retentionDays`);
|
const cached = await cache.get<number>(`org_${orgId}_retentionDays`);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ async function getRetentionDays(orgId: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// store the result in cache
|
// store the result in cache
|
||||||
cache.set(
|
await cache.set(
|
||||||
`org_${orgId}_retentionDays`,
|
`org_${orgId}_retentionDays`,
|
||||||
org.settingsLogRetentionDaysRequest,
|
org.settingsLogRetentionDaysRequest,
|
||||||
300
|
300
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
enforceResourceSessionLength
|
enforceResourceSessionLength
|
||||||
} from "#dynamic/lib/checkOrgAccessPolicy";
|
} from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { logRequestAudit } from "./logRequestAudit";
|
import { logRequestAudit } from "./logRequestAudit";
|
||||||
import cache from "@server/lib/cache";
|
import { localCache } from "#dynamic/lib/cache";
|
||||||
import { APP_VERSION } from "@server/lib/consts";
|
import { APP_VERSION } from "@server/lib/consts";
|
||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -137,7 +137,7 @@ export async function verifyResourceSession(
|
|||||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||||
org: Org;
|
org: Org;
|
||||||
}
|
}
|
||||||
| undefined = cache.get(resourceCacheKey);
|
| undefined = localCache.get(resourceCacheKey);
|
||||||
|
|
||||||
if (!resourceData) {
|
if (!resourceData) {
|
||||||
const result = await getResourceByDomain(cleanHost);
|
const result = await getResourceByDomain(cleanHost);
|
||||||
@@ -161,7 +161,7 @@ export async function verifyResourceSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
resourceData = result;
|
resourceData = result;
|
||||||
cache.set(resourceCacheKey, resourceData, 5);
|
localCache.set(resourceCacheKey, resourceData, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -405,7 +405,7 @@ export async function verifyResourceSession(
|
|||||||
// check for HTTP Basic Auth header
|
// check for HTTP Basic Auth header
|
||||||
const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`;
|
const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`;
|
||||||
if (headerAuth && clientHeaderAuth) {
|
if (headerAuth && clientHeaderAuth) {
|
||||||
if (cache.get(clientHeaderAuthKey)) {
|
if (localCache.get(clientHeaderAuthKey)) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Resource allowed because header auth is valid (cached)"
|
"Resource allowed because header auth is valid (cached)"
|
||||||
);
|
);
|
||||||
@@ -428,7 +428,7 @@ export async function verifyResourceSession(
|
|||||||
headerAuth.headerAuthHash
|
headerAuth.headerAuthHash
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
cache.set(clientHeaderAuthKey, clientHeaderAuth, 5);
|
localCache.set(clientHeaderAuthKey, clientHeaderAuth, 5);
|
||||||
logger.debug("Resource allowed because header auth is valid");
|
logger.debug("Resource allowed because header auth is valid");
|
||||||
|
|
||||||
logRequestAudit(
|
logRequestAudit(
|
||||||
@@ -520,7 +520,7 @@ export async function verifyResourceSession(
|
|||||||
|
|
||||||
if (resourceSessionToken) {
|
if (resourceSessionToken) {
|
||||||
const sessionCacheKey = `session:${resourceSessionToken}`;
|
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||||
let resourceSession: any = cache.get(sessionCacheKey);
|
let resourceSession: any = localCache.get(sessionCacheKey);
|
||||||
|
|
||||||
if (!resourceSession) {
|
if (!resourceSession) {
|
||||||
const result = await validateResourceSessionToken(
|
const result = await validateResourceSessionToken(
|
||||||
@@ -529,7 +529,7 @@ export async function verifyResourceSession(
|
|||||||
);
|
);
|
||||||
|
|
||||||
resourceSession = result?.resourceSession;
|
resourceSession = result?.resourceSession;
|
||||||
cache.set(sessionCacheKey, resourceSession, 5);
|
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceSession?.isRequestToken) {
|
if (resourceSession?.isRequestToken) {
|
||||||
@@ -662,7 +662,7 @@ export async function verifyResourceSession(
|
|||||||
}:${resource.resourceId}`;
|
}:${resource.resourceId}`;
|
||||||
|
|
||||||
let allowedUserData: BasicUserData | null | undefined =
|
let allowedUserData: BasicUserData | null | undefined =
|
||||||
cache.get(userAccessCacheKey);
|
localCache.get(userAccessCacheKey);
|
||||||
|
|
||||||
if (allowedUserData === undefined) {
|
if (allowedUserData === undefined) {
|
||||||
allowedUserData = await isUserAllowedToAccessResource(
|
allowedUserData = await isUserAllowedToAccessResource(
|
||||||
@@ -671,7 +671,7 @@ export async function verifyResourceSession(
|
|||||||
resourceData.org
|
resourceData.org
|
||||||
);
|
);
|
||||||
|
|
||||||
cache.set(userAccessCacheKey, allowedUserData, 5);
|
localCache.set(userAccessCacheKey, allowedUserData, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -974,11 +974,11 @@ async function checkRules(
|
|||||||
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
||||||
const ruleCacheKey = `rules:${resourceId}`;
|
const ruleCacheKey = `rules:${resourceId}`;
|
||||||
|
|
||||||
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
|
let rules: ResourceRule[] | undefined = localCache.get(ruleCacheKey);
|
||||||
|
|
||||||
if (!rules) {
|
if (!rules) {
|
||||||
rules = await getResourceRules(resourceId);
|
rules = await getResourceRules(resourceId);
|
||||||
cache.set(ruleCacheKey, rules, 5);
|
localCache.set(ruleCacheKey, rules, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rules.length === 0) {
|
if (rules.length === 0) {
|
||||||
@@ -1208,13 +1208,13 @@ async function isIpInAsn(
|
|||||||
async function getAsnFromIp(ip: string): Promise<number | undefined> {
|
async function getAsnFromIp(ip: string): Promise<number | undefined> {
|
||||||
const asnCacheKey = `asn:${ip}`;
|
const asnCacheKey = `asn:${ip}`;
|
||||||
|
|
||||||
let cachedAsn: number | undefined = cache.get(asnCacheKey);
|
let cachedAsn: number | undefined = localCache.get(asnCacheKey);
|
||||||
|
|
||||||
if (!cachedAsn) {
|
if (!cachedAsn) {
|
||||||
cachedAsn = await getAsnForIp(ip); // do it locally
|
cachedAsn = await getAsnForIp(ip); // do it locally
|
||||||
// Cache for longer since IP ASN doesn't change frequently
|
// Cache for longer since IP ASN doesn't change frequently
|
||||||
if (cachedAsn) {
|
if (cachedAsn) {
|
||||||
cache.set(asnCacheKey, cachedAsn, 300); // 5 minutes
|
localCache.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> {
|
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
|
||||||
const geoIpCacheKey = `geoip:${ip}`;
|
const geoIpCacheKey = `geoip:${ip}`;
|
||||||
|
|
||||||
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
|
let cachedCountryCode: string | undefined = localCache.get(geoIpCacheKey);
|
||||||
|
|
||||||
if (!cachedCountryCode) {
|
if (!cachedCountryCode) {
|
||||||
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
|
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
|
||||||
// Only cache successful lookups to avoid filling cache with undefined values
|
// Only cache successful lookups to avoid filling cache with undefined values
|
||||||
if (cachedCountryCode) {
|
if (cachedCountryCode) {
|
||||||
// Cache for longer since IP geolocation doesn't change frequently
|
// Cache for longer since IP geolocation doesn't change frequently
|
||||||
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
|
localCache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -689,6 +689,13 @@ authenticated.get(
|
|||||||
user.getOrgUser
|
user.getOrgUser
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user-by-username",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getOrgUser),
|
||||||
|
user.getOrgUserByUsername
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/user/:userId/2fa",
|
"/user/:userId/2fa",
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { Newt } from "@server/db";
|
import { Newt } from "@server/db";
|
||||||
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
|
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
|
|
||||||
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
@@ -24,8 +24,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
logger.info(`Newt ${newt.newtId} has Docker socket access`);
|
logger.info(`Newt ${newt.newtId} has Docker socket access`);
|
||||||
cache.set(`${newt.newtId}:socketPath`, socketPath, 0);
|
await cache.set(`${newt.newtId}:socketPath`, socketPath, 0);
|
||||||
cache.set(`${newt.newtId}:isAvailable`, available, 0);
|
await cache.set(`${newt.newtId}:isAvailable`, available, 0);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Newt ${newt.newtId} does not have Docker socket access`);
|
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) {
|
if (containers && containers.length > 0) {
|
||||||
cache.set(`${newt.newtId}:dockerContainers`, containers, 0);
|
await cache.set(`${newt.newtId}:dockerContainers`, containers, 0);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
|
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import {
|
||||||
|
generateSessionToken,
|
||||||
|
validateSessionToken
|
||||||
|
} from "@server/auth/sessions/app";
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
db,
|
db,
|
||||||
@@ -26,8 +29,9 @@ import { APP_VERSION } from "@server/lib/consts";
|
|||||||
|
|
||||||
export const olmGetTokenBodySchema = z.object({
|
export const olmGetTokenBodySchema = z.object({
|
||||||
olmId: z.string(),
|
olmId: z.string(),
|
||||||
secret: z.string(),
|
secret: z.string().optional(),
|
||||||
token: z.string().optional(),
|
userToken: z.string().optional(),
|
||||||
|
token: z.string().optional(), // this is the olm token
|
||||||
orgId: z.string().optional()
|
orgId: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +53,7 @@ export async function getOlmToken(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { olmId, secret, token, orgId } = parsedBody.data;
|
const { olmId, secret, token, orgId, userToken } = parsedBody.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -84,6 +88,24 @@ 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(
|
const validSecret = await verifyPassword(
|
||||||
secret,
|
secret,
|
||||||
existingOlm.secretHash
|
existingOlm.secretHash
|
||||||
@@ -99,6 +121,14 @@ export async function getOlmToken(
|
|||||||
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
|
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Either secret or userToken is required"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("Creating new olm session token");
|
logger.debug("Creating new olm session token");
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||||
import { doCidrsOverlap } from "@server/lib/ip";
|
import { doCidrsOverlap } from "@server/lib/ip";
|
||||||
import { generateCA } from "@server/private/lib/sshCA";
|
import { generateCA } from "@server/lib/sshCA";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
|
||||||
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
|
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { cache } from "@server/lib/cache";
|
import { cache } from "#dynamic/lib/cache";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||||
@@ -194,9 +194,9 @@ export async function updateOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// invalidate the cache for all of the orgs retention days
|
// invalidate the cache for all of the orgs retention days
|
||||||
cache.del(`org_${orgId}_retentionDays`);
|
await cache.del(`org_${orgId}_retentionDays`);
|
||||||
cache.del(`org_${orgId}_actionDays`);
|
await cache.del(`org_${orgId}_actionDays`);
|
||||||
cache.del(`org_${orgId}_accessDays`);
|
await cache.del(`org_${orgId}_accessDays`);
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: updatedOrg[0],
|
data: updatedOrg[0],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
sites,
|
sites,
|
||||||
userSites
|
userSites
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -23,7 +23,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
|
|
||||||
async function getLatestNewtVersion(): Promise<string | null> {
|
async function getLatestNewtVersion(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const cachedVersion = cache.get<string>("latestNewtVersion");
|
const cachedVersion = await cache.get<string>("latestNewtVersion");
|
||||||
if (cachedVersion) {
|
if (cachedVersion) {
|
||||||
return cachedVersion;
|
return cachedVersion;
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
|
|||||||
tags = tags.filter((version) => !version.name.includes("rc"));
|
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||||
const latestVersion = tags[0].name;
|
const latestVersion = tags[0].name;
|
||||||
|
|
||||||
cache.set("latestNewtVersion", latestVersion);
|
await cache.set("latestNewtVersion", latestVersion);
|
||||||
|
|
||||||
return latestVersion;
|
return latestVersion;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { fetchContainers, dockerSocket } from "../newt/dockerSocket";
|
import { fetchContainers, dockerSocket } from "../newt/dockerSocket";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
|
|
||||||
export interface ContainerNetwork {
|
export interface ContainerNetwork {
|
||||||
networkId: string;
|
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
|
// 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
|
// this is to ensure that the site always gets the latest data
|
||||||
cache.del(`${newt.newtId}:dockerContainers`);
|
await cache.del(`${newt.newtId}:dockerContainers`);
|
||||||
|
|
||||||
return { siteId, newtId: newt.newtId };
|
return { siteId, newtId: newt.newtId };
|
||||||
}
|
}
|
||||||
@@ -158,7 +158,7 @@ async function triggerFetch(siteId: number) {
|
|||||||
async function queryContainers(siteId: number) {
|
async function queryContainers(siteId: number) {
|
||||||
const { newt } = await getSiteAndNewt(siteId);
|
const { newt } = await getSiteAndNewt(siteId);
|
||||||
|
|
||||||
const result = cache.get(`${newt.newtId}:dockerContainers`) as Container[];
|
const result = await cache.get<Container[]>(`${newt.newtId}:dockerContainers`);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw createHttpError(
|
throw createHttpError(
|
||||||
HttpCode.TOO_EARLY,
|
HttpCode.TOO_EARLY,
|
||||||
@@ -173,7 +173,7 @@ async function isDockerAvailable(siteId: number): Promise<boolean> {
|
|||||||
const { newt } = await getSiteAndNewt(siteId);
|
const { newt } = await getSiteAndNewt(siteId);
|
||||||
|
|
||||||
const key = `${newt.newtId}:isAvailable`;
|
const key = `${newt.newtId}:isAvailable`;
|
||||||
const isAvailable = cache.get(key);
|
const isAvailable = await cache.get(key);
|
||||||
|
|
||||||
return !!isAvailable;
|
return !!isAvailable;
|
||||||
}
|
}
|
||||||
@@ -186,9 +186,11 @@ async function getDockerStatus(
|
|||||||
const keys = ["isAvailable", "socketPath"];
|
const keys = ["isAvailable", "socketPath"];
|
||||||
const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`);
|
const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`);
|
||||||
|
|
||||||
|
const values = await cache.mget<boolean | string>(mappedKeys);
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
isAvailable: cache.get(mappedKeys[0]) as boolean,
|
isAvailable: values[0] as boolean,
|
||||||
socketPath: cache.get(mappedKeys[1]) as string | undefined
|
socketPath: values[1] as string | undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
async function queryUser(orgId: string, userId: string) {
|
export async function queryUser(orgId: string, userId: string) {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select({
|
.select({
|
||||||
orgId: userOrgs.orgId,
|
orgId: userOrgs.orgId,
|
||||||
|
|||||||
136
server/routers/user/getOrgUserByUsername.ts
Normal file
136
server/routers/user/getOrgUserByUsername.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { userOrgs, users } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { queryUser, type GetOrgUserResponse } from "./getOrgUser";
|
||||||
|
|
||||||
|
const getOrgUserByUsernameParamsSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const getOrgUserByUsernameQuerySchema = z.strictObject({
|
||||||
|
username: z.string().min(1, "username is required"),
|
||||||
|
idpId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) =>
|
||||||
|
v === undefined || v === "" ? undefined : parseInt(v, 10)
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(v) =>
|
||||||
|
v === undefined || (Number.isInteger(v) && (v as number) > 0),
|
||||||
|
{ message: "idpId must be a positive integer" }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/user-by-username",
|
||||||
|
description:
|
||||||
|
"Get a user in an organization by username. When idpId is not passed, only internal users are searched (username is globally unique for them). For external (OIDC) users, pass idpId to search by username within that identity provider.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.User],
|
||||||
|
request: {
|
||||||
|
params: getOrgUserByUsernameParamsSchema,
|
||||||
|
query: getOrgUserByUsernameQuerySchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getOrgUserByUsername(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export * from "./addUserRole";
|
|||||||
export * from "./inviteUser";
|
export * from "./inviteUser";
|
||||||
export * from "./acceptInvite";
|
export * from "./acceptInvite";
|
||||||
export * from "./getOrgUser";
|
export * from "./getOrgUser";
|
||||||
|
export * from "./getOrgUserByUsername";
|
||||||
export * from "./adminListUsers";
|
export * from "./adminListUsers";
|
||||||
export * from "./adminRemoveUser";
|
export * from "./adminRemoveUser";
|
||||||
export * from "./adminGetUser";
|
export * from "./adminGetUser";
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { UserType } from "@server/types/UserTypes";
|
|||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { FeatureId } from "@server/lib/billing";
|
import { FeatureId } from "@server/lib/billing";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
|
|
||||||
const inviteUserParamsSchema = z.strictObject({
|
const inviteUserParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -191,7 +191,7 @@ export async function inviteUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existingInvite.length) {
|
if (existingInvite.length) {
|
||||||
const attempts = cache.get<number>(email) || 0;
|
const attempts = (await cache.get<number>(email)) || 0;
|
||||||
if (attempts >= 3) {
|
if (attempts >= 3) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -201,7 +201,7 @@ export async function inviteUser(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set(email, attempts + 1);
|
await cache.set(email, attempts + 1);
|
||||||
|
|
||||||
const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId
|
const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId
|
||||||
const token = generateRandomString(
|
const token = generateRandomString(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import semver from "semver";
|
|||||||
import { versionMigrations } from "../db/pg";
|
import { versionMigrations } from "../db/pg";
|
||||||
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
|
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { build } from "@server/build";
|
||||||
import m1 from "./scriptsPg/1.6.0";
|
import m1 from "./scriptsPg/1.6.0";
|
||||||
import m2 from "./scriptsPg/1.7.0";
|
import m2 from "./scriptsPg/1.7.0";
|
||||||
import m3 from "./scriptsPg/1.8.0";
|
import m3 from "./scriptsPg/1.8.0";
|
||||||
@@ -19,7 +20,7 @@ import m11 from "./scriptsPg/1.14.0";
|
|||||||
import m12 from "./scriptsPg/1.15.0";
|
import m12 from "./scriptsPg/1.15.0";
|
||||||
import m13 from "./scriptsPg/1.15.3";
|
import m13 from "./scriptsPg/1.15.3";
|
||||||
import m14 from "./scriptsPg/1.15.4";
|
import m14 from "./scriptsPg/1.15.4";
|
||||||
import { build } from "@server/build";
|
import m15 from "./scriptsPg/1.16.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -39,7 +40,8 @@ const migrations = [
|
|||||||
{ version: "1.14.0", run: m11 },
|
{ version: "1.14.0", run: m11 },
|
||||||
{ version: "1.15.0", run: m12 },
|
{ version: "1.15.0", run: m12 },
|
||||||
{ version: "1.15.3", run: m13 },
|
{ version: "1.15.3", run: m13 },
|
||||||
{ version: "1.15.4", run: m14 }
|
{ version: "1.15.4", run: m14 },
|
||||||
|
{ version: "1.16.0", run: m15 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as {
|
] as {
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { versionMigrations } from "../db/sqlite";
|
|||||||
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
|
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
|
||||||
import { SqliteError } from "better-sqlite3";
|
import { SqliteError } from "better-sqlite3";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import { build } from "@server/build";
|
||||||
import m1 from "./scriptsSqlite/1.0.0-beta1";
|
import m1 from "./scriptsSqlite/1.0.0-beta1";
|
||||||
import m2 from "./scriptsSqlite/1.0.0-beta2";
|
import m2 from "./scriptsSqlite/1.0.0-beta2";
|
||||||
import m3 from "./scriptsSqlite/1.0.0-beta3";
|
import m3 from "./scriptsSqlite/1.0.0-beta3";
|
||||||
@@ -37,7 +38,7 @@ import m32 from "./scriptsSqlite/1.14.0";
|
|||||||
import m33 from "./scriptsSqlite/1.15.0";
|
import m33 from "./scriptsSqlite/1.15.0";
|
||||||
import m34 from "./scriptsSqlite/1.15.3";
|
import m34 from "./scriptsSqlite/1.15.3";
|
||||||
import m35 from "./scriptsSqlite/1.15.4";
|
import m35 from "./scriptsSqlite/1.15.4";
|
||||||
import { build } from "@server/build";
|
import m36 from "./scriptsSqlite/1.16.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -73,7 +74,8 @@ const migrations = [
|
|||||||
{ version: "1.14.0", run: m32 },
|
{ version: "1.14.0", run: m32 },
|
||||||
{ version: "1.15.0", run: m33 },
|
{ version: "1.15.0", run: m33 },
|
||||||
{ version: "1.15.3", run: m34 },
|
{ version: "1.15.3", run: m34 },
|
||||||
{ version: "1.15.4", run: m35 }
|
{ version: "1.15.4", run: m35 },
|
||||||
|
{ version: "1.16.0", run: m36 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
179
server/setup/scriptsPg/1.16.0.ts
Normal file
179
server/setup/scriptsPg/1.16.0.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { db } from "@server/db/pg/driver";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import { generateCA } from "@server/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`);
|
||||||
|
}
|
||||||
@@ -1,23 +1,167 @@
|
|||||||
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import { generateCA } from "@server/lib/sshCA";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
const version = "1.16.0";
|
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() {
|
export default async function migration() {
|
||||||
console.log(`Running setup script ${version}...`);
|
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 location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
const db = new Database(location);
|
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 {
|
try {
|
||||||
db.transaction(() => {})();
|
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(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Migrated database`);
|
console.log(`Migrated database`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -210,7 +210,8 @@ export default function BillingPage() {
|
|||||||
({ subscription }) =>
|
({ subscription }) =>
|
||||||
subscription?.type === "tier1" ||
|
subscription?.type === "tier1" ||
|
||||||
subscription?.type === "tier2" ||
|
subscription?.type === "tier2" ||
|
||||||
subscription?.type === "tier3"
|
subscription?.type === "tier3" ||
|
||||||
|
subscription?.type === "enterprise"
|
||||||
);
|
);
|
||||||
setTierSubscription(tierSub || null);
|
setTierSubscription(tierSub || null);
|
||||||
|
|
||||||
@@ -439,6 +440,8 @@ export default function BillingPage() {
|
|||||||
// Get current plan ID from tier
|
// Get current plan ID from tier
|
||||||
const getCurrentPlanId = (): PlanId => {
|
const getCurrentPlanId = (): PlanId => {
|
||||||
if (!hasSubscription || !currentTier) return "basic";
|
if (!hasSubscription || !currentTier) return "basic";
|
||||||
|
// Handle enterprise subscription type directly
|
||||||
|
if (currentTier === "enterprise") return "enterprise";
|
||||||
const plan = planOptions.find((p) => p.tierType === currentTier);
|
const plan = planOptions.find((p) => p.tierType === currentTier);
|
||||||
return plan?.id || "basic";
|
return plan?.id || "basic";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -187,7 +187,11 @@ export default function ResourceAuthenticationPage() {
|
|||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const [ssoEnabled, setSsoEnabled] = useState(resource.sso);
|
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSsoEnabled(resource.sso ?? false);
|
||||||
|
}, [resource.sso]);
|
||||||
|
|
||||||
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
|
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
|
||||||
resource.skipToIdpId || null
|
resource.skipToIdpId || null
|
||||||
@@ -472,7 +476,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="sso-toggle"
|
id="sso-toggle"
|
||||||
label={t("ssoUse")}
|
label={t("ssoUse")}
|
||||||
defaultChecked={resource.sso}
|
checked={ssoEnabled}
|
||||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -800,8 +804,13 @@ function OneTimePasswordFormSection({
|
|||||||
}: OneTimePasswordFormSectionProps) {
|
}: OneTimePasswordFormSectionProps) {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const [whitelistEnabled, setWhitelistEnabled] = useState(
|
const [whitelistEnabled, setWhitelistEnabled] = useState(
|
||||||
resource.emailWhitelistEnabled
|
resource.emailWhitelistEnabled ?? false
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWhitelistEnabled(resource.emailWhitelistEnabled);
|
||||||
|
}, [resource.emailWhitelistEnabled]);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [loadingSaveWhitelist, startTransition] = useTransition();
|
const [loadingSaveWhitelist, startTransition] = useTransition();
|
||||||
@@ -894,7 +903,7 @@ function OneTimePasswordFormSection({
|
|||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="whitelist-toggle"
|
id="whitelist-toggle"
|
||||||
label={t("otpEmailWhitelist")}
|
label={t("otpEmailWhitelist")}
|
||||||
defaultChecked={resource.emailWhitelistEnabled}
|
checked={whitelistEnabled}
|
||||||
onCheckedChange={setWhitelistEnabled}
|
onCheckedChange={setWhitelistEnabled}
|
||||||
disabled={!env.email.emailEnabled}
|
disabled={!env.email.emailEnabled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -113,7 +113,12 @@ export default function ResourceRules(props: {
|
|||||||
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRulesEnabled(resource.applyRules);
|
||||||
|
}, [resource.applyRules]);
|
||||||
|
|
||||||
const [openCountrySelect, setOpenCountrySelect] = useState(false);
|
const [openCountrySelect, setOpenCountrySelect] = useState(false);
|
||||||
const [countrySelectValue, setCountrySelectValue] = useState("");
|
const [countrySelectValue, setCountrySelectValue] = useState("");
|
||||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||||
@@ -836,7 +841,7 @@ export default function ResourceRules(props: {
|
|||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="rules-toggle"
|
id="rules-toggle"
|
||||||
label={t("rulesEnable")}
|
label={t("rulesEnable")}
|
||||||
defaultChecked={rulesEnabled}
|
checked={rulesEnabled}
|
||||||
onCheckedChange={(val) => setRulesEnabled(val)}
|
onCheckedChange={(val) => setRulesEnabled(val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const orgNavSections = (
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "access",
|
heading: "accessControl",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarTeam",
|
title: "sidebarTeam",
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ const CopyToClipboard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2 min-w-0 max-w-full">
|
<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 ? (
|
{isLink ? (
|
||||||
<Link
|
<Link
|
||||||
href={text}
|
href={text}
|
||||||
@@ -54,18 +66,6 @@ const CopyToClipboard = ({
|
|||||||
{displayValue}
|
{displayValue}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ export function InternalResourceForm({
|
|||||||
title: t("editInternalResourceDialogAccessPolicy"),
|
title: t("editInternalResourceDialogAccessPolicy"),
|
||||||
href: "#"
|
href: "#"
|
||||||
},
|
},
|
||||||
...(disableEnterpriseFeatures
|
...(disableEnterpriseFeatures || mode === "cidr"
|
||||||
? []
|
? []
|
||||||
: [{ title: t("sshAccess"), href: "#" }])
|
: [{ title: t("sshAccess"), href: "#" }])
|
||||||
]}
|
]}
|
||||||
@@ -1188,7 +1188,7 @@ export function InternalResourceForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SSH Access tab */}
|
{/* SSH Access tab */}
|
||||||
{!disableEnterpriseFeatures && (
|
{!disableEnterpriseFeatures && mode !== "cidr" && (
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import { SidebarNav } from "@app/components/SidebarNav";
|
|||||||
import { OrgSelector } from "@app/components/OrgSelector";
|
import { OrgSelector } from "@app/components/OrgSelector";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import SupporterStatus from "@app/components/SupporterStatus";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ExternalLink, Menu, Server } from "lucide-react";
|
import { ArrowRight, Menu, Server } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import ProfileIcon from "@app/components/ProfileIcon";
|
import ProfileIcon from "@app/components/ProfileIcon";
|
||||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||||
@@ -44,7 +42,6 @@ export function LayoutMobileMenu({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isAdminPage = pathname?.startsWith("/admin");
|
const isAdminPage = pathname?.startsWith("/admin");
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const { env } = useEnvContext();
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,7 +80,7 @@ export function LayoutMobileMenu({
|
|||||||
<div className="px-3 pt-3">
|
<div className="px-3 pt-3">
|
||||||
{!isAdminPage &&
|
{!isAdminPage &&
|
||||||
user.serverAdmin && (
|
user.serverAdmin && (
|
||||||
<div className="py-2">
|
<div className="mb-1">
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -98,11 +95,12 @@ export function LayoutMobileMenu({
|
|||||||
<span className="flex-shrink-0 mr-2">
|
<span className="flex-shrink-0 mr-2">
|
||||||
<Server className="h-4 w-4" />
|
<Server className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="flex-1">
|
||||||
{t(
|
{t(
|
||||||
"serverAdmin"
|
"serverAdmin"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -115,22 +113,6 @@ export function LayoutMobileMenu({
|
|||||||
</div>
|
</div>
|
||||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 pt-3 pb-3 space-y-4 border-t shrink-0">
|
|
||||||
<SupporterStatus />
|
|
||||||
{env?.app?.version && (
|
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
|
||||||
<Link
|
|
||||||
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center justify-center gap-1"
|
|
||||||
>
|
|
||||||
v{env.app.version}
|
|
||||||
<ExternalLink size={12} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,18 +146,13 @@ export function LayoutSidebar({
|
|||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto relative">
|
<div className="flex-1 overflow-y-auto relative">
|
||||||
<div className="px-2 pt-3">
|
<div className="px-2 pt-3">
|
||||||
<SidebarNav
|
|
||||||
sections={navItems}
|
|
||||||
isCollapsed={isSidebarCollapsed}
|
|
||||||
notificationCounts={notificationCounts}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Fade gradient at bottom to indicate scrollable content */}
|
|
||||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isAdminPage && user.serverAdmin && (
|
{!isAdminPage && user.serverAdmin && (
|
||||||
<div className="shrink-0 px-2 pb-2">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -167,7 +162,9 @@ export function LayoutSidebar({
|
|||||||
: "px-3 py-1.5"
|
: "px-3 py-1.5"
|
||||||
)}
|
)}
|
||||||
title={
|
title={
|
||||||
isSidebarCollapsed ? t("serverAdmin") : undefined
|
isSidebarCollapsed
|
||||||
|
? t("serverAdmin")
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -189,6 +186,15 @@ export function LayoutSidebar({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<SidebarNav
|
||||||
|
sections={navItems}
|
||||||
|
isCollapsed={isSidebarCollapsed}
|
||||||
|
notificationCounts={notificationCounts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Fade gradient at bottom to indicate scrollable content */}
|
||||||
|
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{isSidebarCollapsed && (
|
{isSidebarCollapsed && (
|
||||||
<div className="shrink-0 flex justify-center py-2">
|
<div className="shrink-0 flex justify-center py-2">
|
||||||
@@ -218,7 +224,7 @@ export function LayoutSidebar({
|
|||||||
|
|
||||||
<div className="w-full border-t border-border mb-3" />
|
<div className="w-full border-t border-border mb-3" />
|
||||||
|
|
||||||
<div className="p-4 pt-0 mt-0 flex flex-col shrink-0">
|
<div className="p-4 pt-1 flex flex-col shrink-0">
|
||||||
{canShowProductUpdates && (
|
{canShowProductUpdates && (
|
||||||
<div className="mb-3 empty:mb-0">
|
<div className="mb-3 empty:mb-0">
|
||||||
<ProductUpdates isCollapsed={isSidebarCollapsed} />
|
<ProductUpdates isCollapsed={isSidebarCollapsed} />
|
||||||
|
|||||||
@@ -2,31 +2,20 @@ import { headers } from "next/headers";
|
|||||||
|
|
||||||
export async function authCookieHeader() {
|
export async function authCookieHeader() {
|
||||||
const otherHeaders = await headers();
|
const otherHeaders = await headers();
|
||||||
const otherHeadersObject = Object.fromEntries(otherHeaders.entries());
|
const otherHeadersObject = Object.fromEntries(
|
||||||
|
Array.from(otherHeaders.entries()).map(([k, v]) => [k.toLowerCase(), v])
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
cookie:
|
cookie: otherHeadersObject["cookie"],
|
||||||
otherHeadersObject["cookie"] || otherHeadersObject["Cookie"],
|
host: otherHeadersObject["host"],
|
||||||
host: otherHeadersObject["host"] || otherHeadersObject["Host"],
|
"user-agent": otherHeadersObject["user-agent"],
|
||||||
"user-agent":
|
"x-forwarded-for": otherHeadersObject["x-forwarded-for"],
|
||||||
otherHeadersObject["user-agent"] ||
|
"x-forwarded-host": otherHeadersObject["x-forwarded-host"],
|
||||||
otherHeadersObject["User-Agent"],
|
"x-forwarded-port": otherHeadersObject["x-forwarded-port"],
|
||||||
"x-forwarded-for":
|
"x-forwarded-proto": otherHeadersObject["x-forwarded-proto"],
|
||||||
otherHeadersObject["x-forwarded-for"] ||
|
"x-real-ip": otherHeadersObject["x-real-ip"]
|
||||||
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"]
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user