mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-17 16:19:48 +02:00
Compare commits
82 Commits
1.23.0-rc.0
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f453dc04f | |||
| 65ccd5a89d | |||
| 76a4f50ccf | |||
| 1dfe0124be | |||
| aa52174f96 | |||
| e23ae5707f | |||
| 17053ac2bd | |||
| 735787b183 | |||
| 262ca8a1d0 | |||
| 66c9bdbfa3 | |||
| 4716a2a647 | |||
| 847f44197b | |||
| 6aacd4d185 | |||
| ab3db3bc68 | |||
| b52baceb50 | |||
| 0d2c8a37ef | |||
| 9298ec7cdb | |||
| 0fdff2feee | |||
| 8e042e6433 | |||
| 883ad14326 | |||
| 3cb41211ee | |||
| ee4a1a6b18 | |||
| ee3f9efa27 | |||
| a10972990a | |||
| c32ef54428 | |||
| 6d6e105711 | |||
| bda09a1b74 | |||
| 178d1d9779 | |||
| d311eb6e8c | |||
| 829680e984 | |||
| be10dcd26e | |||
| cc23e8cf5d | |||
| fc77b5bb3e | |||
| 651acae326 | |||
| 75332a1d1f | |||
| 0f1631f592 | |||
| f1a303de14 | |||
| 191c062cec | |||
| 1839ea7ca3 | |||
| a3c2c98c5e | |||
| 43e42f90f8 | |||
| a954ba349b | |||
| f07aef4ace | |||
| 4afa01aba4 | |||
| 9b15e99cfb | |||
| a322d0479c | |||
| bb9761a171 | |||
| af4d80edec | |||
| da434f63b4 | |||
| 9718c3549b | |||
| d550eef6c2 | |||
| c76b591f9f | |||
| a1b7c69ba5 | |||
| 20e5d537b0 | |||
| 197cfdc339 | |||
| 418bb26ee6 | |||
| 4dfa81416b | |||
| 324f3e50ff | |||
| 64bb6d9f9c | |||
| e54d1aa73e | |||
| c6c12f1dcb | |||
| 4b30911f06 | |||
| c6c443f0e0 | |||
| 24a06dd8ca | |||
| c11bbe670f | |||
| 7ed646c02b | |||
| 9d5dd526bc | |||
| 9e25650f22 | |||
| 143b4cf757 | |||
| 25b151a021 | |||
| c62fb6cbe0 | |||
| e561c9f334 | |||
| 2b970ce6e7 | |||
| e8cb0297a1 | |||
| 4f7e993e70 | |||
| 8dc3a1378a | |||
| 3713b714e4 | |||
| 098157f671 | |||
| ea1b3e5270 | |||
| 899620a890 | |||
| c19cb706e8 | |||
| 938d7f145c |
@@ -266,7 +266,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: 1.25
|
||||
go-version: 1.26
|
||||
|
||||
- name: Update version in package.json
|
||||
run: |
|
||||
|
||||
@@ -26,7 +26,10 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
# Match the version the app ships on (Dockerfile /
|
||||
# Dockerfile.dev both use 24.18.1) rather than floating
|
||||
# to the latest 24.x.
|
||||
node-version: '24.18.1'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -19,7 +19,13 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
# Pin to the version the app actually ships on (Dockerfile /
|
||||
# Dockerfile.dev both use 24.18.1; .nvmrc pins the 24 major).
|
||||
# A bare '24' floats to the latest 24.x; Node >= 24.19.0 added
|
||||
# node::ObjectWrap cleanup hooks that crash better-sqlite3 on teardown
|
||||
# ("Assertion failed: (env) != nullptr" in RemoveEnvironmentCleanupHook),
|
||||
# which intermittently kills `npm run dev` in the step below.
|
||||
node-version: '24.18.1'
|
||||
|
||||
- name: Copy config file
|
||||
run: cp config/config.example.yml config/config.yml
|
||||
@@ -43,19 +49,34 @@ jobs:
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Start app in background
|
||||
run: nohup npm run dev &
|
||||
run: |
|
||||
nohup npm run dev > app.log 2>&1 &
|
||||
echo $! > app.pid
|
||||
|
||||
- name: Wait for app availability
|
||||
run: |
|
||||
print_log() {
|
||||
if [ -f app.log ]; then cat app.log; else echo "(app.log not found)"; fi
|
||||
}
|
||||
for i in {1..5}; do
|
||||
if curl --silent --fail http://localhost:3002/auth/login; then
|
||||
echo "App is up"
|
||||
exit 0
|
||||
fi
|
||||
# Fail fast (and show why) if the process is gone. Read the PID
|
||||
# safely: if it's missing/empty (e.g. nohup never started), treat
|
||||
# that as "not running" instead of passing a bad arg to kill.
|
||||
pid="$(cat app.pid 2>/dev/null || true)"
|
||||
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo "App process exited before becoming available. Output:"
|
||||
print_log
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for the app... attempt $i"
|
||||
sleep 5
|
||||
done
|
||||
echo "App failed to start"
|
||||
echo "App failed to start. Output:"
|
||||
print_log
|
||||
exit 1
|
||||
|
||||
build-sqlite:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# typescript-eslint@8.70.0 declares a peer range of typescript "<6.1.0" and
|
||||
# hasn't caught up to typescript@7.x yet, even though it works fine against it
|
||||
# in practice. Without this, `npm install`/`npm ci` fail with ERESOLVE.
|
||||
legacy-peer-deps=true
|
||||
+1
-1
@@ -5,7 +5,7 @@ WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
COPY package*.json .npmrc ./
|
||||
|
||||
FROM base AS builder-dev
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
COPY package*.json .npmrc ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
@@ -142,6 +142,7 @@ Give users a landing page to quickly find and open the resources they can access
|
||||
* Create reusable views for common access patterns
|
||||
|
||||
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
|
||||
<img src="public/screenshots/resource-launcher-expanded.png" alt="Resource Launcher Details Panel" width="100%" />
|
||||
|
||||
## Download Clients
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
## Example Docker Reference HA Deployment
|
||||
|
||||
This directory contains basic config for a highly available deployment of Pangolin with two nodes. For more information [refer to the docs](/self-host/clustering/understanding-clustering).
|
||||
This directory contains a basic reference config for a highly available deployment of Pangolin with two nodes. For more information [refer to the docs](https://docs.pangolin.net/self-host/clustering/understanding-clustering).
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
module installer
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/term v0.46.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -33,6 +33,6 @@ require (
|
||||
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.47.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
||||
+4
-4
@@ -69,10 +69,10 @@ 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
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=
|
||||
|
||||
+4
-2
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Няма намерени сайтове.",
|
||||
"pangolinServerAdmin": "Администратор на сървър - Панголин",
|
||||
"licenseTierProfessional": "Професионален лиценз",
|
||||
"licenseTierEnterprise": "Предприятие лиценз",
|
||||
"licenseTierPersonal": "Персонален лиценз",
|
||||
"licenseTierEnterprise": "Предприятие",
|
||||
"licenseTierPersonal": "Личен",
|
||||
"licenseTierTier1": "Начален",
|
||||
"licenseTierTier2": "Мащаб",
|
||||
"licensed": "Лицензиран",
|
||||
"yes": "Да",
|
||||
"no": "Не",
|
||||
|
||||
+5
-3
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Nebyly nalezeny žádné stránky.",
|
||||
"pangolinServerAdmin": "Správce serveru - Pangolin",
|
||||
"licenseTierProfessional": "Profesionální licence",
|
||||
"licenseTierEnterprise": "Podniková licence",
|
||||
"licenseTierPersonal": "Osobní licence",
|
||||
"licenseTierEnterprise": "Podniky",
|
||||
"licenseTierPersonal": "Osobní",
|
||||
"licenseTierTier1": "Počáteční",
|
||||
"licenseTierTier2": "Měřítko",
|
||||
"licensed": "Licencováno",
|
||||
"yes": "Ano",
|
||||
"no": "Ne",
|
||||
@@ -3979,7 +3981,7 @@
|
||||
"tpmAvailable": "TPM k dispozici",
|
||||
"windowsAntivirusEnabled": "Antivirus povolen",
|
||||
"macosSipEnabled": "Ochrana systémové integrity (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosGatekeeperEnabled": "Strážce",
|
||||
"macosFirewallStealthMode": "Režim neviditelnosti firewallu",
|
||||
"linuxAppArmorEnabled": "Pancíř aplikace",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
|
||||
+5
-3
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Ingen sites fundet.",
|
||||
"pangolinServerAdmin": "Serveradmin - Pangolin",
|
||||
"licenseTierProfessional": "Professionel licens",
|
||||
"licenseTierEnterprise": "Enterprise-licens",
|
||||
"licenseTierPersonal": "Personlig licens",
|
||||
"licenseTierEnterprise": "Bedrift",
|
||||
"licenseTierPersonal": "Personlig",
|
||||
"licenseTierTier1": "Begynder",
|
||||
"licenseTierTier2": "Skala",
|
||||
"licensed": "Licenseret",
|
||||
"yes": "Ja",
|
||||
"no": "Nej",
|
||||
@@ -2068,7 +2070,7 @@
|
||||
"aiUsageFilterNotFound": "Ingen valg fundet",
|
||||
"aiUsageResetFilters": "Nulstil Filtre",
|
||||
"aiUsageRefresh": "Opdater",
|
||||
"aiUsageTokenTypePrompt": "Prompt",
|
||||
"aiUsageTokenTypePrompt": "Vis",
|
||||
"aiUsageTokenTypeCacheRead": "Cache læs",
|
||||
"aiUsageTokenTypeCacheWrite": "Cache skriv",
|
||||
"aiUsageTokenTypeCompletion": "Komplettering",
|
||||
|
||||
+5
-3
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Keine Standorte gefunden.",
|
||||
"pangolinServerAdmin": "Server-Admin - Pangolin",
|
||||
"licenseTierProfessional": "Professional Lizenz",
|
||||
"licenseTierEnterprise": "Enterprise Lizenz",
|
||||
"licenseTierPersonal": "Persönliche Lizenz",
|
||||
"licenseTierEnterprise": "Firma",
|
||||
"licenseTierPersonal": "Persönlich",
|
||||
"licenseTierTier1": "Starter",
|
||||
"licenseTierTier2": "Maßstab",
|
||||
"licensed": "Lizenziert",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
@@ -3205,7 +3207,7 @@
|
||||
"idpAzureClientIdDescription2": "Azure App Registration Client ID",
|
||||
"idpAzureClientSecretDescription2": "Azure App Registration Client Geheimnis",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC Provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC-Anbieter",
|
||||
"subnet": "Subnetz",
|
||||
"utilitySubnet": "Nutzsubnetz",
|
||||
"subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.",
|
||||
|
||||
+7
-2
@@ -234,6 +234,9 @@
|
||||
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||
"privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
|
||||
"licenseBillingBannerTitle": "Manage License Billing",
|
||||
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
|
||||
"licenseBillingBannerButton": "Go to Billing",
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
@@ -1118,8 +1121,10 @@
|
||||
"sitesNotFound": "No sites found.",
|
||||
"pangolinServerAdmin": "Server Admin - Pangolin",
|
||||
"licenseTierProfessional": "Professional License",
|
||||
"licenseTierEnterprise": "Enterprise License",
|
||||
"licenseTierPersonal": "Personal License",
|
||||
"licenseTierEnterprise": "Enterprise",
|
||||
"licenseTierPersonal": "Personal",
|
||||
"licenseTierTier1": "Starter",
|
||||
"licenseTierTier2": "Scale",
|
||||
"licensed": "Licensed",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
|
||||
+4
-2
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Sitios no encontrados.",
|
||||
"pangolinServerAdmin": "Admin Servidor - Pangolin",
|
||||
"licenseTierProfessional": "Licencia profesional",
|
||||
"licenseTierEnterprise": "Licencia Enterprise",
|
||||
"licenseTierPersonal": "Licencia personal",
|
||||
"licenseTierEnterprise": "Empresa",
|
||||
"licenseTierPersonal": "Uso personal",
|
||||
"licenseTierTier1": "Iniciador",
|
||||
"licenseTierTier2": "Escala",
|
||||
"licensed": "Licenciado",
|
||||
"yes": "Sí",
|
||||
"no": "Nu",
|
||||
|
||||
+4
-2
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Aucun site trouvé.",
|
||||
"pangolinServerAdmin": "Admin Serveur - Pangolin",
|
||||
"licenseTierProfessional": "Licence Professionnelle",
|
||||
"licenseTierEnterprise": "Licence Entreprise",
|
||||
"licenseTierPersonal": "Licence personnelle",
|
||||
"licenseTierEnterprise": "Entreprise",
|
||||
"licenseTierPersonal": "Personnel",
|
||||
"licenseTierTier1": "Démarrage",
|
||||
"licenseTierTier2": "Échelle",
|
||||
"licensed": "Sous licence",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
|
||||
+4
-2
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Nessun sito trovato.",
|
||||
"pangolinServerAdmin": "Server Admin - Pangolina",
|
||||
"licenseTierProfessional": "Licenza Professional",
|
||||
"licenseTierEnterprise": "Licenza Enterprise",
|
||||
"licenseTierPersonal": "Licenza Personale",
|
||||
"licenseTierEnterprise": "Impresa",
|
||||
"licenseTierPersonal": "Personale",
|
||||
"licenseTierTier1": "Avviatore",
|
||||
"licenseTierTier2": "Scala",
|
||||
"licensed": "Con Licenza",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
|
||||
+4
-2
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "사이트를 찾을 수 없습니다.",
|
||||
"pangolinServerAdmin": "서버 관리자 - 판골린",
|
||||
"licenseTierProfessional": "전문 라이센스",
|
||||
"licenseTierEnterprise": "기업 라이선스",
|
||||
"licenseTierPersonal": "개인 라이선스",
|
||||
"licenseTierEnterprise": "기업",
|
||||
"licenseTierPersonal": "개인",
|
||||
"licenseTierTier1": "스타터",
|
||||
"licenseTierTier2": "스케일",
|
||||
"licensed": "라이센스",
|
||||
"yes": "예",
|
||||
"no": "아니요",
|
||||
|
||||
+20
-18
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Ingen områder funnet.",
|
||||
"pangolinServerAdmin": "Server Admin - Pangolin",
|
||||
"licenseTierProfessional": "Profesjonell lisens",
|
||||
"licenseTierEnterprise": "Bedriftslisens",
|
||||
"licenseTierPersonal": "Personlig lisens",
|
||||
"licenseTierEnterprise": "Bedrift",
|
||||
"licenseTierPersonal": "Personlig",
|
||||
"licenseTierTier1": "Begynner",
|
||||
"licenseTierTier2": "Skala",
|
||||
"licensed": "Lisensiert",
|
||||
"yes": "Ja",
|
||||
"no": "Nei",
|
||||
@@ -2816,7 +2818,7 @@
|
||||
"roleTextImportPreview": "Forhåndsvisning",
|
||||
"roleTextImportItemCount": "{count, plural, =0 {Ingen elementer å importere} one {ett element å importere} other {# elementer å importere}}",
|
||||
"roleTextImportTotalCount": "{existing} eksisterende + {imported} importert = {total} totalt",
|
||||
"roleTextImportConfirm": "Import",
|
||||
"roleTextImportConfirm": "Importer",
|
||||
"roleTextImportInvalidFile": "Ustøttet filtype",
|
||||
"roleTextImportInvalidFileDescription": "Bare .txt og .csv filer er støttet.",
|
||||
"roleTextImportEmpty": "Ingen elementer funnet i filen",
|
||||
@@ -3093,7 +3095,7 @@
|
||||
"regionAfrica": "Afrika",
|
||||
"regionNorthernAfrica": "[country name] Nord-Afrika",
|
||||
"regionEasternAfrica": "Øst-Afrika",
|
||||
"regionMiddleAfrica": "Middle Africa",
|
||||
"regionMiddleAfrica": "Midt-Afrika",
|
||||
"regionSouthernAfrica": "Sør-Afrika",
|
||||
"regionWesternAfrica": "[country name] Vest-Afrika",
|
||||
"regionAmericas": "Amerika",
|
||||
@@ -3112,10 +3114,10 @@
|
||||
"regionNorthernEurope": "Nord-Europa",
|
||||
"regionSouthernEurope": "Sørlige Europa",
|
||||
"regionWesternEurope": "Vest-Europa",
|
||||
"regionOceania": "Oceania",
|
||||
"regionOceania": "Oseania",
|
||||
"regionAustraliaAndNewZealand": "Australia og New Zealand",
|
||||
"regionMelanesia": "Melanesia",
|
||||
"regionMicronesia": "Micronesia",
|
||||
"regionMicronesia": "Mikronesia",
|
||||
"regionPolynesia": "Polynesia",
|
||||
"managedSelfHosted": {
|
||||
"title": "Administrert selv-hostet",
|
||||
@@ -3205,7 +3207,7 @@
|
||||
"idpAzureClientIdDescription2": "Azure App registrerings klient-ID",
|
||||
"idpAzureClientSecretDescription2": "Azure App Registrering Klient Hemmelig",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC leverandør",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC-leverandør",
|
||||
"subnet": "Subnett",
|
||||
"utilitySubnet": "Nyttesubnett",
|
||||
"subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.",
|
||||
@@ -3220,7 +3222,7 @@
|
||||
"authPageBrandingRemoveTitle": "Fjern markedsføring for autentiseringsside",
|
||||
"authPageBrandingQuestionRemove": "Er du sikker på at du vil fjerne merkevarebyggingen for autentiseringssider?",
|
||||
"authPageBrandingDeleteConfirm": "Bekreft sletting av merkevarebygging",
|
||||
"brandingLogoURL": "Logo URL",
|
||||
"brandingLogoURL": "Logo-URL",
|
||||
"brandingLogoURLOrPath": "Logoen URL eller sti",
|
||||
"brandingLogoPathDescription": "Skriv inn en URL eller en lokal bane.",
|
||||
"brandingLogoURLDescription": "Skriv inn en offentlig tilgjengelig nettadresse til din logobilde.",
|
||||
@@ -3360,7 +3362,7 @@
|
||||
"resourceHeaderAuthSetupTitleDescription": "Angi grunnleggende auth legitimasjon (brukernavn og passord) for å beskytte denne ressursen med HTTP Header autentisering. Tilgang til det ved hjelp av formatet https://username:password@resource.example.com",
|
||||
"resourceHeaderAuthSubmit": "Angi topptekst godkjenning",
|
||||
"actionSetResourceHeaderAuth": "Angi topptekst godkjenning",
|
||||
"enterpriseEdition": "Enterprise Edition",
|
||||
"enterpriseEdition": "Enterprise-utgave",
|
||||
"unlicensed": "Ikke lisensiert",
|
||||
"beta": "beta",
|
||||
"manageUserDevices": "Bruker Enheter",
|
||||
@@ -3501,7 +3503,7 @@
|
||||
"priority": "Prioritet",
|
||||
"priorityDescription": "Høyere prioriterte ruter evalueres først. Prioritet = 100 betyr automatisk bestilling (systembeslutninger). Bruk et annet nummer til å håndheve manuell prioritet.",
|
||||
"instanceName": "Forekomst navn",
|
||||
"clearInstanceName": "Reset Server Association",
|
||||
"clearInstanceName": "Tilbakestill server-assosiasjon",
|
||||
"pathMatchModalTitle": "Konfigurere matching av sti",
|
||||
"pathMatchModalDescription": "Sett opp hvordan innkommende forespørsler skal matches basert på deres bane.",
|
||||
"pathMatchType": "Trefftype",
|
||||
@@ -3557,11 +3559,11 @@
|
||||
"allowedByRule": "Tillatt etter regel",
|
||||
"allowedNoAuth": "Tillatt Ingen Auth",
|
||||
"validAccessToken": "Gyldig tilgangsnøkkel",
|
||||
"validHeaderAuth": "Valid header auth",
|
||||
"validHeaderAuth": "Gyldig header-autentisering",
|
||||
"validPincode": "Gyldig PIN-kode",
|
||||
"validPassword": "Gyldig passord",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"validEmail": "Gyldig e-post",
|
||||
"validSSO": "Gyldig SSO",
|
||||
"validVirtualAPIKey": "Gyldig Virtuell API-nøkkel",
|
||||
"view": "Vis",
|
||||
"configManaged": "Konfigurasjon administrert",
|
||||
@@ -3570,7 +3572,7 @@
|
||||
"droppedByRule": "Legg i regelen",
|
||||
"noSessions": "Ingen økter",
|
||||
"temporaryRequestToken": "Midlertidig forespørsel Token",
|
||||
"noMoreAuthMethods": "No Valid Auth",
|
||||
"noMoreAuthMethods": "Ingen gyldig autentisering",
|
||||
"ip": "IP",
|
||||
"reason": "Grunn",
|
||||
"requestLogs": "HTTP-forespørselslogger",
|
||||
@@ -3784,14 +3786,14 @@
|
||||
"niceIdUpdateErrorDescription": "Det oppstod en feil under oppdatering av Nice ID.",
|
||||
"niceIdCannotBeEmpty": "God ID kan ikke være tom",
|
||||
"enterIdentifier": "Angi identifikator",
|
||||
"identifier": "Identifier",
|
||||
"identifier": "Identifikator",
|
||||
"deviceLoginUseDifferentAccount": "Ikke du? Bruk en annen konto.",
|
||||
"deviceLoginDeviceRequestingAccessToAccount": "En enhet ber om tilgang til denne kontoen.",
|
||||
"loginSelectAuthenticationMethod": "Velg en autentiseringsmetode for å fortsette.",
|
||||
"noData": "Ingen data",
|
||||
"machineClients": "Maskinklienter",
|
||||
"install": "Installer",
|
||||
"downloadInstaller": "Download Installer",
|
||||
"downloadInstaller": "Last ned installasjonsprogram",
|
||||
"run": "Kjør",
|
||||
"envFile": "Miljøfil",
|
||||
"serviceFile": "Tjenestefil",
|
||||
@@ -3969,7 +3971,7 @@
|
||||
"kernelVersion": "Kjerne versjon",
|
||||
"deviceModel": "Enhets modell",
|
||||
"serialNumber": "Serienummer",
|
||||
"hostname": "Hostname",
|
||||
"hostname": "Vertsnavn",
|
||||
"firstSeen": "Først sett",
|
||||
"lastSeen": "Sist sett",
|
||||
"biometricsEnabled": "Biometri aktivert",
|
||||
@@ -3999,7 +4001,7 @@
|
||||
"disconnected": "Frakoblet",
|
||||
"approvalsEmptyStateTitle": "Enhetsgodkjenninger er ikke aktivert",
|
||||
"approvalsEmptyStateDescription": "Aktivere godkjenninger av enheter for at roller må godkjennes av admin før brukere kan koble til nye enheter.",
|
||||
"approvalsEmptyStateHowToTitle": "How to Enable",
|
||||
"approvalsEmptyStateHowToTitle": "Hvordan aktivere",
|
||||
"approvalsEmptyStateStep1Title": "Gå til roller",
|
||||
"approvalsEmptyStateStep1Description": "Naviger til organisasjonens roller innstillinger for å konfigurere enhetsgodkjenninger.",
|
||||
"approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger",
|
||||
|
||||
+5
-3
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Geen sites gevonden.",
|
||||
"pangolinServerAdmin": "Serverbeheer - Pangolin",
|
||||
"licenseTierProfessional": "Professionele licentie",
|
||||
"licenseTierEnterprise": "Enterprise Licentie",
|
||||
"licenseTierPersonal": "Persoonlijke licentie",
|
||||
"licenseTierEnterprise": "Onderneming",
|
||||
"licenseTierPersonal": "Persoonlijk",
|
||||
"licenseTierTier1": "Beginner",
|
||||
"licenseTierTier2": "Schaal",
|
||||
"licensed": "Gelicentieerd",
|
||||
"yes": "ja",
|
||||
"no": "Neen",
|
||||
@@ -3205,7 +3207,7 @@
|
||||
"idpAzureClientIdDescription2": "Azure App registratie Client ID",
|
||||
"idpAzureClientSecretDescription2": "Azure App registratie client geheim",
|
||||
"idpGoogleDescription": "Algemene OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC-provider",
|
||||
"subnet": "Subnet",
|
||||
"utilitySubnet": "Hulpmiddel Subnet",
|
||||
"subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.",
|
||||
|
||||
+6
-4
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Nie znaleziono witryn.",
|
||||
"pangolinServerAdmin": "Administrator serwera - Pangolin",
|
||||
"licenseTierProfessional": "Licencja Professional",
|
||||
"licenseTierEnterprise": "Licencja Enterprise",
|
||||
"licenseTierPersonal": "Licencja osobista",
|
||||
"licenseTierEnterprise": "Przedsiębiorstwo",
|
||||
"licenseTierPersonal": "Osobiste",
|
||||
"licenseTierTier1": "Startowy",
|
||||
"licenseTierTier2": "Skala",
|
||||
"licensed": "Licencjonowany",
|
||||
"yes": "Tak",
|
||||
"no": "Nie",
|
||||
@@ -3791,7 +3793,7 @@
|
||||
"noData": "Brak danych",
|
||||
"machineClients": "Klienci maszyn",
|
||||
"install": "Zainstaluj",
|
||||
"downloadInstaller": "Download Installer",
|
||||
"downloadInstaller": "Pobierz instalator",
|
||||
"run": "Uruchom",
|
||||
"envFile": "Plik środowiska",
|
||||
"serviceFile": "Plik serwisu",
|
||||
@@ -3999,7 +4001,7 @@
|
||||
"disconnected": "Rozłączony",
|
||||
"approvalsEmptyStateTitle": "Zatwierdzanie urządzenia nie włączone",
|
||||
"approvalsEmptyStateDescription": "Włącz zatwierdzanie urządzeń dla ról aby wymagać zgody administratora, zanim użytkownicy będą mogli podłączyć nowe urządzenia.",
|
||||
"approvalsEmptyStateHowToTitle": "How to Enable",
|
||||
"approvalsEmptyStateHowToTitle": "Jak włączyć",
|
||||
"approvalsEmptyStateStep1Title": "Przejdź do ról",
|
||||
"approvalsEmptyStateStep1Description": "Przejdź do ustawień ról swojej organizacji, aby skonfigurować zatwierdzenia urządzenia.",
|
||||
"approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia",
|
||||
|
||||
+4
-2
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Nenhum site encontrado.",
|
||||
"pangolinServerAdmin": "Administrador do Servidor - Pangolin",
|
||||
"licenseTierProfessional": "Licença Profissional",
|
||||
"licenseTierEnterprise": "Licença Empresarial",
|
||||
"licenseTierPersonal": "Licença Pessoal",
|
||||
"licenseTierEnterprise": "Empresa",
|
||||
"licenseTierPersonal": "Pessoal",
|
||||
"licenseTierTier1": "Iniciante",
|
||||
"licenseTierTier2": "Escala",
|
||||
"licensed": "Licenciado",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
|
||||
+4
-2
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Сайты не найдены.",
|
||||
"pangolinServerAdmin": "Администратор сервера - Pangolin",
|
||||
"licenseTierProfessional": "Профессиональная лицензия",
|
||||
"licenseTierEnterprise": "Корпоративная лицензия",
|
||||
"licenseTierPersonal": "Личная лицензия",
|
||||
"licenseTierEnterprise": "Предприятие",
|
||||
"licenseTierPersonal": "Личное",
|
||||
"licenseTierTier1": "Старт",
|
||||
"licenseTierTier2": "Масштаб",
|
||||
"licensed": "Лицензировано",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
|
||||
+4
-2
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "Site bulunamadı.",
|
||||
"pangolinServerAdmin": "Sunucu Yöneticisi - Pangolin",
|
||||
"licenseTierProfessional": "Profesyonel Lisans",
|
||||
"licenseTierEnterprise": "Kurumsal Lisans",
|
||||
"licenseTierPersonal": "Kişisel Lisans",
|
||||
"licenseTierEnterprise": "Kurumsal",
|
||||
"licenseTierPersonal": "Kişisel",
|
||||
"licenseTierTier1": "Başlangıç",
|
||||
"licenseTierTier2": "Ölçek",
|
||||
"licensed": "Lisanslı",
|
||||
"yes": "Evet",
|
||||
"no": "Hayır",
|
||||
|
||||
+4
-2
@@ -1118,8 +1118,10 @@
|
||||
"sitesNotFound": "未找到站点。",
|
||||
"pangolinServerAdmin": "服务器管理 - Pangolin",
|
||||
"licenseTierProfessional": "专业许可证",
|
||||
"licenseTierEnterprise": "企业许可证",
|
||||
"licenseTierPersonal": "个人许可证",
|
||||
"licenseTierEnterprise": "企业",
|
||||
"licenseTierPersonal": "个人",
|
||||
"licenseTierTier1": "启动器",
|
||||
"licenseTierTier2": "扩展",
|
||||
"licensed": "已授权",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
|
||||
Generated
+2660
-1913
File diff suppressed because it is too large
Load Diff
+29
-29
@@ -33,13 +33,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "9.1.0",
|
||||
"@aws-sdk/client-s3": "3.1121.0",
|
||||
"@aws-sdk/client-s3": "3.1131.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@node-rs/argon2": "2.2.0",
|
||||
"@node-rs/argon2": "2.2.1",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
@@ -65,8 +65,8 @@
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.3",
|
||||
"@simplewebauthn/browser": "14.0.0",
|
||||
"@simplewebauthn/server": "14.0.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
@@ -99,29 +99,29 @@
|
||||
"js-yaml": "5.4.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lru-cache": "11.5.2",
|
||||
"lucide-react": "1.38.0",
|
||||
"lucide-react": "1.45.0",
|
||||
"maxmind": "5.0.7",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.3.3",
|
||||
"next-intl": "4.14.1",
|
||||
"next": "16.3.5",
|
||||
"next-intl": "4.14.4",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"nodemailer": "9.1.0",
|
||||
"nodemailer": "10.0.9",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.23.0",
|
||||
"posthog-node": "5.51.4",
|
||||
"posthog-node": "5.52.1",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.8",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.8",
|
||||
"react": "19.3.0",
|
||||
"react-day-picker": "10.0.1",
|
||||
"react-dom": "19.3.0",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.87.0",
|
||||
"react-hook-form": "7.88.0",
|
||||
"react-icons": "5.7.0",
|
||||
"recharts": "3.10.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"semver": "7.8.5",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.6.0",
|
||||
"stripe": "22.6.2",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
@@ -133,15 +133,15 @@
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.21.3",
|
||||
"yaml": "2.9.0",
|
||||
"yaml": "2.9.1",
|
||||
"yargs": "18.1.0",
|
||||
"zod": "4.5.4",
|
||||
"zod": "4.6.2",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "2.23.0",
|
||||
"@dotenvx/dotenvx": "2.24.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.9.3",
|
||||
"@react-email/ui": "^6.9.5",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tanstack/react-query-devtools": "5.102.8",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
@@ -155,12 +155,12 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "26.4.0",
|
||||
"@types/node": "26.5.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/sshpk": "1.17.5",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
@@ -171,20 +171,20 @@
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild-node-externals": "2.0.0",
|
||||
"eslint": "10.9.1",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"postcss": "8.5.26",
|
||||
"eslint": "10.10.0",
|
||||
"eslint-config-next": "16.3.5",
|
||||
"postcss": "8.5.28",
|
||||
"prettier": "3.9.6",
|
||||
"react-email": "6.9.3",
|
||||
"react-email": "6.9.5",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsc-alias": "1.9.2",
|
||||
"tsc-alias": "1.9.5",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0"
|
||||
"typescript": "7.0.2",
|
||||
"typescript-eslint": "8.70.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.2",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.26"
|
||||
"postcss": "8.5.28"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 711 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 713 KiB |
+12
-8
@@ -1,5 +1,6 @@
|
||||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||
import { readEnvOrFile } from "@server/lib/getEnvOrYaml";
|
||||
import { withReplicas } from "drizzle-orm/pg-core";
|
||||
import { createPool } from "./poolConfig";
|
||||
|
||||
@@ -7,17 +8,20 @@ function createDb() {
|
||||
const config = readConfigFile();
|
||||
|
||||
// check the environment variables for postgres config first before the config file
|
||||
if (process.env.POSTGRES_CONNECTION_STRING) {
|
||||
const envConnectionString = readEnvOrFile("POSTGRES_CONNECTION_STRING");
|
||||
if (envConnectionString) {
|
||||
config.postgres = {
|
||||
connection_string: process.env.POSTGRES_CONNECTION_STRING
|
||||
connection_string: envConnectionString
|
||||
};
|
||||
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
|
||||
const replicas =
|
||||
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map(
|
||||
(conn) => ({
|
||||
connection_string: conn.trim()
|
||||
})
|
||||
const replicaConnectionStrings = readEnvOrFile(
|
||||
"POSTGRES_REPLICA_CONNECTION_STRINGS"
|
||||
);
|
||||
if (replicaConnectionStrings) {
|
||||
const replicas = replicaConnectionStrings
|
||||
.split(",")
|
||||
.map((conn) => ({
|
||||
connection_string: conn.trim()
|
||||
}));
|
||||
config.postgres.replicas = replicas;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||
import { readEnvOrFile } from "@server/lib/getEnvOrYaml";
|
||||
import { withReplicas } from "drizzle-orm/pg-core";
|
||||
import { build } from "@server/build";
|
||||
import { db as mainDb } from "./driver";
|
||||
@@ -17,7 +18,7 @@ function createLogsDb() {
|
||||
const logsConfig = config.postgres_logs;
|
||||
|
||||
// Check environment variable first
|
||||
let connectionString = process.env.POSTGRES_LOGS_CONNECTION_STRING;
|
||||
let connectionString = readEnvOrFile("POSTGRES_LOGS_CONNECTION_STRING");
|
||||
let replicaConnections: Array<{ connection_string: string }> = [];
|
||||
|
||||
if (!connectionString && logsConfig) {
|
||||
@@ -26,13 +27,15 @@ function createLogsDb() {
|
||||
}
|
||||
|
||||
// If POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS is set, use it
|
||||
if (process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS) {
|
||||
replicaConnections =
|
||||
process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS.split(",").map(
|
||||
(conn) => ({
|
||||
connection_string: conn.trim()
|
||||
})
|
||||
const replicaConnectionStrings = readEnvOrFile(
|
||||
"POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS"
|
||||
);
|
||||
if (replicaConnectionStrings) {
|
||||
replicaConnections = replicaConnectionStrings
|
||||
.split(",")
|
||||
.map((conn) => ({
|
||||
connection_string: conn.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
// If no logs database is configured, fall back to main database
|
||||
|
||||
@@ -6,6 +6,7 @@ import fs from "fs";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import logger from "@server/logger";
|
||||
import { readEnvOrFile } from "@server/lib/getEnvOrYaml";
|
||||
|
||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
export const exists = checkFileExists(location);
|
||||
@@ -19,7 +20,7 @@ function createDb() {
|
||||
: undefined;
|
||||
const sqlite = new Database(location, { verbose });
|
||||
|
||||
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||
if (readEnvOrFile("ENABLE_SQLITE_WAL_MODE") == "true") {
|
||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||
// contention across subsystems (verifySession, Traefik, audit, ping).
|
||||
// NOTE: journal_mode persists in the DB file once set; unsetting this
|
||||
|
||||
@@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({
|
||||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
If you need to purchase additional license keys or
|
||||
modify your existing license, please reach out to
|
||||
our support team at{" "}
|
||||
For any questions or concerns regarding your license
|
||||
or billing, please reach out to support at{" "}
|
||||
<a
|
||||
href="mailto:support@pangolin.net"
|
||||
className="text-primary font-medium"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { formatBackupTimestamp } from "./backupFileName";
|
||||
import { assertEquals } from "@test/assert";
|
||||
|
||||
// Local-time constructors are used throughout, matching formatBackupTimestamp,
|
||||
// so these cases do not depend on the machine's timezone.
|
||||
|
||||
function testMonthIsOneIndexed() {
|
||||
console.log("Running month indexing tests...");
|
||||
|
||||
// The case from the report: a backup taken on 12 September 2026 was named
|
||||
// db_2026-8-12_... because Date#getMonth is zero-indexed.
|
||||
{
|
||||
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 35, 56));
|
||||
assertEquals(
|
||||
result,
|
||||
"2026-09-12_20-35-56",
|
||||
"September must render as 09, not 8"
|
||||
);
|
||||
}
|
||||
|
||||
// The other reported name, db_2026-0-23_..., was a January backup.
|
||||
{
|
||||
const result = formatBackupTimestamp(new Date(2026, 0, 23, 20, 25, 49));
|
||||
assertEquals(
|
||||
result,
|
||||
"2026-01-23_20-25-49",
|
||||
"January must render as 01, not 0"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const result = formatBackupTimestamp(new Date(2026, 11, 31, 23, 59, 59));
|
||||
assertEquals(
|
||||
result,
|
||||
"2026-12-31_23-59-59",
|
||||
"December must render as 12"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function testEveryFieldIsZeroPadded() {
|
||||
console.log("Running zero padding tests...");
|
||||
|
||||
// db_2026-8-12_20-36-2 in the report: a single-digit second was not padded.
|
||||
{
|
||||
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 36, 2));
|
||||
assertEquals(
|
||||
result,
|
||||
"2026-09-12_20-36-02",
|
||||
"Single-digit seconds must be padded"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const result = formatBackupTimestamp(new Date(2026, 0, 1, 0, 0, 0));
|
||||
assertEquals(
|
||||
result,
|
||||
"2026-01-01_00-00-00",
|
||||
"Midnight on the first of the month must pad every field"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function testNamesSortChronologically() {
|
||||
console.log("Running sort order tests...");
|
||||
|
||||
// Zero padding means a plain lexicographic sort of the backups directory
|
||||
// lists the backups in the order they were taken.
|
||||
const taken = [
|
||||
new Date(2026, 8, 12, 20, 36, 2),
|
||||
new Date(2026, 0, 23, 20, 25, 49),
|
||||
new Date(2026, 8, 12, 20, 35, 56),
|
||||
new Date(2026, 11, 31, 23, 59, 59)
|
||||
];
|
||||
|
||||
const sorted = taken
|
||||
.map((date) => formatBackupTimestamp(date))
|
||||
.sort();
|
||||
|
||||
assertEquals(
|
||||
sorted.join(","),
|
||||
[
|
||||
"2026-01-23_20-25-49",
|
||||
"2026-09-12_20-35-56",
|
||||
"2026-09-12_20-36-02",
|
||||
"2026-12-31_23-59-59"
|
||||
].join(","),
|
||||
"Backup names must sort into the order the backups were taken"
|
||||
);
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
testMonthIsOneIndexed();
|
||||
testEveryFieldIsZeroPadded();
|
||||
testNamesSortChronologically();
|
||||
console.log("All tests passed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Builds the timestamp segment of a database backup file name.
|
||||
*
|
||||
* `Date#getMonth` is zero-indexed, so building this inline produced names like
|
||||
* `db_2026-8-12_...` for a backup taken on 12 September 2026. Every field is
|
||||
* also zero-padded, which keeps the names unambiguous and makes them sort
|
||||
* lexicographically in the order they were taken.
|
||||
*
|
||||
* @param date The moment the backup is being taken. Defaults to now.
|
||||
* @returns A timestamp of the form `YYYY-MM-DD_HH-MM-SS`.
|
||||
*/
|
||||
export function formatBackupTimestamp(date: Date = new Date()): string {
|
||||
const pad = (value: number): string => String(value).padStart(2, "0");
|
||||
|
||||
const datePart = [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate())
|
||||
].join("-");
|
||||
|
||||
const timePart = [
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds())
|
||||
].join("-");
|
||||
|
||||
return `${datePart}_${timePart}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export enum LicenseId {
|
||||
SMALL_LICENSE = "small_license",
|
||||
BIG_LICENSE = "big_license"
|
||||
TIER1 = "tier1",
|
||||
TIER2 = "tier2"
|
||||
}
|
||||
|
||||
export type LicensePriceSet = {
|
||||
@@ -9,15 +9,15 @@ export type LicensePriceSet = {
|
||||
|
||||
export const licensePriceSet: LicensePriceSet = {
|
||||
// Free license matches the freeLimitSet
|
||||
[LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
|
||||
[LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
|
||||
[LicenseId.TIER1]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
|
||||
[LicenseId.TIER2]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
|
||||
};
|
||||
|
||||
export const licensePriceSetSandbox: LicensePriceSet = {
|
||||
// Free license matches the freeLimitSet
|
||||
// when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value
|
||||
[LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
|
||||
[LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl"
|
||||
[LicenseId.TIER1]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
|
||||
[LicenseId.TIER2]: "price_1SxDy0DCpkOb237BWJxrxYkl"
|
||||
};
|
||||
|
||||
export function getLicensePriceSet(
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||
return process.env[envVar] ?? valFromYaml;
|
||||
import fs from "fs";
|
||||
|
||||
// Resolves an environment variable, also honoring a `<envVar>_FILE` variant
|
||||
// that points to a file whose (trimmed) contents should be used as the
|
||||
// value. This is the common convention for consuming Docker/Swarm secrets
|
||||
// (e.g. mounted at /run/secrets/...) without putting the raw value in the
|
||||
// container's environment.
|
||||
export const readEnvOrFile = (envVar: string): string | undefined => {
|
||||
const fileEnvVar = `${envVar}_FILE`;
|
||||
const filePath = process.env[fileEnvVar];
|
||||
|
||||
if (filePath) {
|
||||
if (process.env[envVar]) {
|
||||
throw new Error(
|
||||
`Both ${envVar} and ${fileEnvVar} are set. Please set only one.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8").trim();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read ${fileEnvVar} (${filePath}): ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return process.env[envVar];
|
||||
};
|
||||
|
||||
export const getEnvOrYaml =
|
||||
(envVar: string) =>
|
||||
(valFromYaml: string | undefined): string | undefined => {
|
||||
return readEnvOrFile(envVar) ?? valFromYaml;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as yaml from "js-yaml";
|
||||
import { configFilePath1, configFilePath2 } from "./consts";
|
||||
import { z } from "zod";
|
||||
import stoi from "./stoi";
|
||||
import { getEnvOrYaml } from "./getEnvOrYaml";
|
||||
import { getEnvOrYaml, readEnvOrFile } from "./getEnvOrYaml";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
@@ -26,14 +26,12 @@ export const configSchema = z
|
||||
.object({
|
||||
anonymous_usage: z.boolean().optional().default(true)
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
notifications: z
|
||||
.object({
|
||||
product_updates: z.boolean().optional().default(true),
|
||||
new_releases: z.boolean().optional().default(true)
|
||||
})
|
||||
.optional()
|
||||
.prefault({})
|
||||
})
|
||||
.optional()
|
||||
@@ -109,7 +107,6 @@ export const configSchema = z
|
||||
id: z.string().optional().default("P-Access-Token-Id"),
|
||||
token: z.string().optional().default("P-Access-Token")
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
remote_headers: z
|
||||
.object({
|
||||
@@ -126,7 +123,6 @@ export const configSchema = z
|
||||
name: z.string().optional().default("Remote-Name"),
|
||||
role: z.string().optional().default("Remote-Role")
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
resource_session_request_param: z
|
||||
.string()
|
||||
@@ -164,14 +160,17 @@ export const configSchema = z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.transform((val) =>
|
||||
process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !==
|
||||
undefined
|
||||
? process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER ===
|
||||
"true"
|
||||
: val
|
||||
),
|
||||
secret: z.string().pipe(z.string().min(8)).optional(),
|
||||
.transform((val) => {
|
||||
const envVal = readEnvOrFile(
|
||||
"ENABLE_AI_GATEWAY_CLIENT_IP_HEADER"
|
||||
);
|
||||
return envVal !== undefined ? envVal === "true" : val;
|
||||
}),
|
||||
secret: z
|
||||
.string()
|
||||
.pipe(z.string().min(8))
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_SECRET")),
|
||||
maxmind_db_path: z.string().optional(),
|
||||
maxmind_asn_path: z.string().optional()
|
||||
})
|
||||
@@ -202,7 +201,8 @@ export const configSchema = z
|
||||
dashboard_session_length_hours: 720,
|
||||
resource_session_length_hours: 720,
|
||||
trust_proxy: 1,
|
||||
enable_ai_gateway_client_ip_header: false
|
||||
enable_ai_gateway_client_ip_header: false,
|
||||
secret: undefined
|
||||
}),
|
||||
postgres: z
|
||||
.object({
|
||||
@@ -238,7 +238,6 @@ export const configSchema = z
|
||||
.default(5000),
|
||||
jit_mode: z.boolean().default(true)
|
||||
})
|
||||
.optional()
|
||||
.prefault({})
|
||||
})
|
||||
.optional(),
|
||||
@@ -278,7 +277,6 @@ export const configSchema = z
|
||||
.optional()
|
||||
.default(5000)
|
||||
})
|
||||
.optional()
|
||||
.prefault({})
|
||||
})
|
||||
.optional(),
|
||||
@@ -325,10 +323,8 @@ export const configSchema = z
|
||||
.optional()
|
||||
.default(50)
|
||||
})
|
||||
.optional()
|
||||
.prefault({})
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
gerbil: z
|
||||
.object({
|
||||
@@ -357,7 +353,6 @@ export const configSchema = z
|
||||
.optional()
|
||||
.default(30)
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
orgs: z
|
||||
.object({
|
||||
@@ -391,7 +386,6 @@ export const configSchema = z
|
||||
.optional()
|
||||
.default(500)
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
auth: z
|
||||
.object({
|
||||
@@ -408,10 +402,8 @@ export const configSchema = z
|
||||
.optional()
|
||||
.default(500)
|
||||
})
|
||||
.optional()
|
||||
.prefault({})
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
email: z
|
||||
.object({
|
||||
@@ -489,10 +481,8 @@ export const configSchema = z
|
||||
.optional()
|
||||
.default(12)
|
||||
})
|
||||
.optional()
|
||||
.prefault({})
|
||||
})
|
||||
.optional()
|
||||
.prefault({})
|
||||
})
|
||||
.refine(
|
||||
@@ -513,10 +503,7 @@ export const configSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// If hybrid is not defined, server secret must be defined. If its not defined already then pull it from env
|
||||
if (data.server?.secret === undefined) {
|
||||
data.server.secret = process.env.SERVER_SECRET;
|
||||
}
|
||||
// If hybrid is not defined, server secret must be defined
|
||||
return (
|
||||
data.server?.secret !== undefined &&
|
||||
data.server.secret.length > 0
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getTraefikConfig } from "#dynamic/lib/traefik";
|
||||
import { getValidCertificatesForDomains } from "@server/lib/certificates";
|
||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||
import { build } from "@server/build";
|
||||
import license from "#dynamic/license/license";
|
||||
|
||||
export class TraefikConfigManager {
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
@@ -357,7 +358,11 @@ export class TraefikConfigManager {
|
||||
this.lastActiveDomains = new Set(domains);
|
||||
}
|
||||
|
||||
if (process.env.CERT_MODE === "pangolin" && build != "oss") {
|
||||
if (
|
||||
process.env.CERT_MODE === "pangolin" &&
|
||||
build != "oss" &&
|
||||
(await license.hasTier(["personal", "tier2", "enterprise"]))
|
||||
) {
|
||||
// Scan current local certificate state
|
||||
this.lastLocalCertificateState =
|
||||
await this.scanLocalCertificateState();
|
||||
@@ -717,10 +722,9 @@ export class TraefikConfigManager {
|
||||
}
|
||||
if (shouldWrite) {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
this.atomicWriteFileSync(
|
||||
traefikDynamicConfigPath,
|
||||
yaml.dump(traefikConfig, { noRefs: true }),
|
||||
"utf8"
|
||||
yaml.dump(traefikConfig, { noRefs: true })
|
||||
);
|
||||
logger.info("Traefik dynamic config updated");
|
||||
} catch (err) {
|
||||
@@ -822,7 +826,7 @@ export class TraefikConfigManager {
|
||||
// Only write the config if it has changed
|
||||
const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
|
||||
if (newConfigYaml !== originalConfigYaml) {
|
||||
fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8");
|
||||
this.atomicWriteFileSync(dynamicConfigPath, newConfigYaml);
|
||||
logger.info("Dynamic cert config updated from local certificates");
|
||||
}
|
||||
}
|
||||
@@ -900,26 +904,23 @@ export class TraefikConfigManager {
|
||||
`Processing certificate for domain: ${cert.domain}`
|
||||
);
|
||||
|
||||
fs.writeFileSync(certPath, cert.certFile, "utf8");
|
||||
fs.writeFileSync(keyPath, cert.keyFile, "utf8");
|
||||
|
||||
// Set appropriate permissions (readable by owner only for key file)
|
||||
fs.chmodSync(certPath, 0o644);
|
||||
fs.chmodSync(keyPath, 0o600);
|
||||
// Write atomically (temp file + rename) so Traefik's
|
||||
// file watcher never observes a partially written
|
||||
// cert/key and fails with "failed to find any PEM data".
|
||||
this.atomicWriteFileSync(certPath, cert.certFile, 0o644);
|
||||
this.atomicWriteFileSync(keyPath, cert.keyFile, 0o600);
|
||||
|
||||
// Write/update .last_update file with current timestamp
|
||||
fs.writeFileSync(
|
||||
this.atomicWriteFileSync(
|
||||
lastUpdatePath,
|
||||
new Date().toISOString(),
|
||||
"utf8"
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
// Check if this is a wildcard certificate and store it
|
||||
const wildcardPath = path.join(domainDir, ".wildcard");
|
||||
fs.writeFileSync(
|
||||
this.atomicWriteFileSync(
|
||||
wildcardPath,
|
||||
cert.wildcard ? "true" : "false",
|
||||
"utf8"
|
||||
cert.wildcard ? "true" : "false"
|
||||
);
|
||||
|
||||
logger.info(
|
||||
@@ -931,10 +932,9 @@ export class TraefikConfigManager {
|
||||
// even if the cert content didn't change
|
||||
if (cert.expiresAt) {
|
||||
const expiresAtPath = path.join(domainDir, ".expires_at");
|
||||
fs.writeFileSync(
|
||||
this.atomicWriteFileSync(
|
||||
expiresAtPath,
|
||||
cert.expiresAt.toString(),
|
||||
"utf8"
|
||||
cert.expiresAt.toString()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -970,7 +970,7 @@ export class TraefikConfigManager {
|
||||
// Only write the config if it has changed
|
||||
const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
|
||||
if (newConfigYaml !== originalConfigYaml) {
|
||||
fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8");
|
||||
this.atomicWriteFileSync(dynamicConfigPath, newConfigYaml);
|
||||
logger.info("Dynamic cert config updated");
|
||||
}
|
||||
}
|
||||
@@ -1141,10 +1141,9 @@ export class TraefikConfigManager {
|
||||
|
||||
if (configChanged) {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
this.atomicWriteFileSync(
|
||||
dynamicConfigPath,
|
||||
yaml.dump(dynamicConfig, { noRefs: true }),
|
||||
"utf8"
|
||||
yaml.dump(dynamicConfig, { noRefs: true })
|
||||
);
|
||||
logger.info("Dynamic config updated after cleanup");
|
||||
} catch (err) {
|
||||
@@ -1171,6 +1170,36 @@ export class TraefikConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a file atomically by writing to a temp file in the same
|
||||
* directory and renaming it into place. This avoids Traefik (which
|
||||
* watches these files/directories) picking up a partially written
|
||||
* file and failing to parse it (e.g. "failed to find any PEM data").
|
||||
*/
|
||||
private atomicWriteFileSync(
|
||||
filePath: string,
|
||||
data: string,
|
||||
mode?: number
|
||||
): void {
|
||||
const dir = path.dirname(filePath);
|
||||
const tmpPath = path.join(
|
||||
dir,
|
||||
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, data, "utf8");
|
||||
if (mode !== undefined) {
|
||||
fs.chmodSync(tmpPath, mode);
|
||||
}
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.rmSync(tmpPath, { force: true });
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import { setHostMeta } from "@server/lib/hostMeta";
|
||||
const keyTypes = ["host"] as const;
|
||||
export type LicenseKeyType = (typeof keyTypes)[number];
|
||||
|
||||
const keyTiers = ["personal", "enterprise"] as const;
|
||||
const keyTiers = ["personal", "enterprise", "tier1", "tier2"] as const;
|
||||
export type LicenseKeyTier = (typeof keyTiers)[number];
|
||||
|
||||
export type LicenseStatus = {
|
||||
@@ -50,6 +50,10 @@ export class License {
|
||||
public async isUnlocked() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public async hasTier(tier: LicenseKeyTier[]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await setHostMeta();
|
||||
|
||||
@@ -45,9 +45,9 @@ export class JobScheduler {
|
||||
label: string
|
||||
): () => Promise<void> {
|
||||
return async () => {
|
||||
if (!(await license.isUnlocked())) {
|
||||
if (!(await license.hasTier(["personal", "tier2", "enterprise"]))) {
|
||||
logger.debug(
|
||||
`Skipping ${label} tick - license is not subscribed`
|
||||
`Skipping ${label} tick - requires a tier2 license`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,11 @@ export class AuthoritativeDNSServer {
|
||||
private allDomains: Set<string> = new Set();
|
||||
private domainRefreshInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
// Cached license/subscription status. license.isUnlocked() does a DB
|
||||
// round-trip on every call, so it can't be checked per-query on a UDP
|
||||
// server that may see very high query volume - instead it's polled on
|
||||
// the same cadence as the domain set refresh and read from memory here.
|
||||
// Cached license/plan status - only a tier2 license unlocks the DNS
|
||||
// server. license.hasPlan() does a DB round-trip on every call, so it
|
||||
// can't be checked per-query on a UDP server that may see very high
|
||||
// query volume - instead it's polled on the same cadence as the domain
|
||||
// set refresh and read from memory here.
|
||||
private isLicensed: boolean = false;
|
||||
private licenseRefreshInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -128,7 +129,7 @@ export class AuthoritativeDNSServer {
|
||||
}
|
||||
|
||||
if (!this.isLicensed) {
|
||||
logger.debug("Refusing DNS query - license is not subscribed");
|
||||
logger.debug("Refusing DNS query - requires a tier2 license");
|
||||
// REFUSED (rcode=5) indicates a policy refusal by this nameserver.
|
||||
this.sendResponse(packet, [], rinfo, false, 5, []);
|
||||
return;
|
||||
@@ -1042,7 +1043,11 @@ export class AuthoritativeDNSServer {
|
||||
|
||||
private async refreshLicenseStatus(): Promise<void> {
|
||||
try {
|
||||
this.isLicensed = await license.isUnlocked();
|
||||
this.isLicensed = await license.hasTier([
|
||||
"personal",
|
||||
"tier2",
|
||||
"enterprise"
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error("Failed to refresh license status:", error);
|
||||
this.isLicensed = false;
|
||||
|
||||
@@ -48,7 +48,6 @@ export const privateConfigSchema = z
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
redis: z
|
||||
.object({
|
||||
@@ -166,7 +165,6 @@ export const privateConfigSchema = z
|
||||
.optional()
|
||||
.default("http://gerbil:3004")
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
flags: z
|
||||
.object({
|
||||
@@ -187,7 +185,6 @@ export const privateConfigSchema = z
|
||||
// (server/private/lib/config.ts).
|
||||
disable_private_http_placeholder: z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
acme: z
|
||||
.object({
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
getValidCertificatesForDomains
|
||||
} from "@server/lib/certificates";
|
||||
import { build } from "@server/build";
|
||||
import license from "#private/license/license";
|
||||
import regionalCache from "#private/lib/cache";
|
||||
import { TargetWithSite } from "@server/lib/traefik/types";
|
||||
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
|
||||
@@ -395,8 +396,15 @@ export async function getTraefikConfig(
|
||||
)
|
||||
);
|
||||
|
||||
// Pangolin-managed DNS-01/ACME cert mode requires either a tier1
|
||||
// license (self-hosted) or a saas build - otherwise fall back to
|
||||
// Traefik's own cert resolvers (buildWildcardTls) throughout.
|
||||
const pangolinCertModeEnabled =
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin" &&
|
||||
(await license.hasTier(["personal", "tier2", "enterprise"]));
|
||||
|
||||
let validCerts: CertificateResult[] = [];
|
||||
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
|
||||
if (pangolinCertModeEnabled) {
|
||||
// create a list of all domains to get certs for
|
||||
const domains = new Set<string>();
|
||||
for (const resource of resourcesMap.values()) {
|
||||
@@ -522,10 +530,7 @@ export async function getTraefikConfig(
|
||||
);
|
||||
|
||||
let tls = {};
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
) {
|
||||
if (!pangolinCertModeEnabled) {
|
||||
tls = buildWildcardTls({
|
||||
fullDomain,
|
||||
hasSubdomain: !!resource.subdomain,
|
||||
@@ -546,6 +551,30 @@ export async function getTraefikConfig(
|
||||
}
|
||||
}
|
||||
|
||||
config_output.http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: buildHttpLoadBalancerServers(targets),
|
||||
...(resource.stickySession
|
||||
? buildStickySessionCookie(resource.ssl)
|
||||
: {})
|
||||
}
|
||||
};
|
||||
|
||||
if (resource.tlsServerName) {
|
||||
if (!config_output.http.serversTransports) {
|
||||
config_output.http.serversTransports = {};
|
||||
}
|
||||
config_output.http.serversTransports![transportName] = {
|
||||
serverName: resource.tlsServerName,
|
||||
//unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings
|
||||
// if defined in the static config and here. if not set, self-signed certs won't work
|
||||
insecureSkipVerify: true
|
||||
};
|
||||
config_output.http.services![
|
||||
serviceName
|
||||
].loadBalancer.serversTransport = transportName;
|
||||
}
|
||||
|
||||
if (resource.ssl) {
|
||||
config_output.http.routers![routerName + "-redirect"] = {
|
||||
entryPoints: [
|
||||
@@ -710,31 +739,6 @@ export async function getTraefikConfig(
|
||||
priority: priority,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
config_output.http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: buildHttpLoadBalancerServers(targets),
|
||||
...(resource.stickySession
|
||||
? buildStickySessionCookie(resource.ssl)
|
||||
: {})
|
||||
}
|
||||
};
|
||||
|
||||
// Add the serversTransport if TLS server name is provided
|
||||
if (resource.tlsServerName) {
|
||||
if (!config_output.http.serversTransports) {
|
||||
config_output.http.serversTransports = {};
|
||||
}
|
||||
config_output.http.serversTransports![transportName] = {
|
||||
serverName: resource.tlsServerName,
|
||||
//unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings
|
||||
// if defined in the static config and here. if not set, self-signed certs won't work
|
||||
insecureSkipVerify: true
|
||||
};
|
||||
config_output.http.services![
|
||||
serviceName
|
||||
].loadBalancer.serversTransport = transportName;
|
||||
}
|
||||
} else if (resource.mode == "tcp" || resource.mode == "udp") {
|
||||
// Non-HTTP (TCP/UDP) configuration
|
||||
if (!resource.enableProxy) {
|
||||
@@ -791,10 +795,7 @@ export async function getTraefikConfig(
|
||||
domainCertResolver,
|
||||
preferWildcardCert
|
||||
}) => {
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
) {
|
||||
if (!pangolinCertModeEnabled) {
|
||||
return buildWildcardTls({
|
||||
fullDomain,
|
||||
hasSubdomain,
|
||||
@@ -835,10 +836,7 @@ export async function getTraefikConfig(
|
||||
maintenancePageUiUrl,
|
||||
redirectHttpsMiddlewareName,
|
||||
resolveTls: (fullDomain) => {
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
) {
|
||||
if (!pangolinCertModeEnabled) {
|
||||
// siteResource aliases don't have a per-domain cert
|
||||
// resolver stored, so always fall back to the global
|
||||
// defaults.
|
||||
@@ -929,10 +927,7 @@ export async function getTraefikConfig(
|
||||
const rule = buildHostRule(fullDomain, ir.wildcard);
|
||||
|
||||
let tls: any = {};
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
) {
|
||||
if (!pangolinCertModeEnabled) {
|
||||
tls = buildWildcardTls({
|
||||
fullDomain,
|
||||
hasSubdomain: !!ir.subdomain,
|
||||
@@ -1012,10 +1007,7 @@ export async function getTraefikConfig(
|
||||
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
|
||||
|
||||
let tls: any = {};
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
) {
|
||||
if (!pangolinCertModeEnabled) {
|
||||
// siteResource aliases don't have a per-domain cert
|
||||
// resolver stored, so always fall back to the global
|
||||
// defaults.
|
||||
@@ -1089,7 +1081,7 @@ export async function getTraefikConfig(
|
||||
.where(eq(exitNodes.exitNodeId, exitNodeId));
|
||||
|
||||
let validCertsLoginPages: CertificateResult[] = [];
|
||||
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
|
||||
if (pangolinCertModeEnabled) {
|
||||
// create a list of all domains to get certs for
|
||||
const domains = new Set<string>();
|
||||
for (const lp of exitNodeLoginPages) {
|
||||
@@ -1134,10 +1126,7 @@ export async function getTraefikConfig(
|
||||
}
|
||||
|
||||
const tls = {};
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||
"pangolin"
|
||||
) {
|
||||
if (!pangolinCertModeEnabled) {
|
||||
// TODO: we need to add the wildcard logic here too
|
||||
} else {
|
||||
// find a cert that matches the full domain, if not continue
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
LicenseStatus
|
||||
} from "@server/license/license";
|
||||
import { setHostMeta } from "@server/lib/hostMeta";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type ActivateLicenseKeyAPIResponse = {
|
||||
data: {
|
||||
@@ -119,6 +120,9 @@ LQIDAQAB
|
||||
}
|
||||
|
||||
public async isUnlocked(): Promise<boolean> {
|
||||
if (build == "saas") {
|
||||
return true;
|
||||
}
|
||||
const status = await this.check();
|
||||
if (status.isHostLicensed) {
|
||||
if (status.isLicenseValid) {
|
||||
@@ -128,6 +132,20 @@ LQIDAQAB
|
||||
return false;
|
||||
}
|
||||
|
||||
public async hasTier(tier: LicenseKeyTier[]): Promise<boolean> {
|
||||
if (build == "saas") {
|
||||
return true;
|
||||
}
|
||||
const status = await this.check();
|
||||
if (status.isHostLicensed && status.isLicenseValid) {
|
||||
return (
|
||||
status.tier !== undefined &&
|
||||
tier.includes(status.tier as LicenseKeyTier)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async check(): Promise<LicenseStatus> {
|
||||
// If a check is already in progress, return the last known status
|
||||
if (this.checkInProgress) {
|
||||
@@ -135,8 +153,7 @@ LQIDAQAB
|
||||
"License check already in progress, returning last known status"
|
||||
);
|
||||
const lastStatus = this.statusCache.get(this.statusKey) as
|
||||
| LicenseStatus
|
||||
| undefined;
|
||||
LicenseStatus | undefined;
|
||||
if (lastStatus) {
|
||||
return lastStatus;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ export const queryAccessAuditLogsQuery = z.object({
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
|
||||
@@ -47,7 +47,6 @@ export const queryActionAuditLogsQuery = z.object({
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
|
||||
@@ -55,7 +55,6 @@ export const queryConnectionAuditLogsQuery = z.object({
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
|
||||
@@ -222,15 +222,16 @@ export async function handleSubscriptionCreated(
|
||||
|
||||
let numUsers: number;
|
||||
let numSites: number;
|
||||
let tier = "enterprise";
|
||||
|
||||
if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) {
|
||||
if (subscriptionPriceId === priceSet[LicenseId.TIER1]) {
|
||||
numUsers = 25;
|
||||
numSites = 25;
|
||||
} else if (
|
||||
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
|
||||
) {
|
||||
tier = "tier1";
|
||||
} else if (subscriptionPriceId === priceSet[LicenseId.TIER2]) {
|
||||
numUsers = 50;
|
||||
numSites = 100;
|
||||
tier = "tier2";
|
||||
} else {
|
||||
logger.error(
|
||||
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
|
||||
@@ -242,6 +243,14 @@ export async function handleSubscriptionCreated(
|
||||
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
|
||||
);
|
||||
|
||||
// Grace period of 5 days added on top of the current billing
|
||||
// period end (usually ~1 year out) before the license expires
|
||||
const currentPeriodEnd =
|
||||
fullSubscription.items.data[0]?.current_period_end;
|
||||
const expiresAt =
|
||||
(currentPeriodEnd ?? subscription.created) +
|
||||
5 * 24 * 60 * 60;
|
||||
|
||||
const response = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
|
||||
{
|
||||
@@ -256,7 +265,9 @@ export async function handleSubscriptionCreated(
|
||||
licenseId: parseInt(licenseId),
|
||||
paidFor: true,
|
||||
users: numUsers,
|
||||
sites: numSites
|
||||
sites: numSites,
|
||||
tier: tier,
|
||||
expiresAt: expiresAt
|
||||
})
|
||||
}
|
||||
);
|
||||
@@ -265,6 +276,13 @@ export async function handleSubscriptionCreated(
|
||||
|
||||
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${response.status} when setting paid-for for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${JSON.stringify(data)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (customer.email) {
|
||||
logger.debug(
|
||||
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
|
||||
|
||||
@@ -125,9 +125,7 @@ export async function handleSubscriptionDeleted(
|
||||
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||
);
|
||||
try {
|
||||
// WARNING:
|
||||
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||
await fetch(
|
||||
const invalidateResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -139,9 +137,18 @@ export async function handleSubscriptionDeleted(
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orgId: customer.orgId,
|
||||
licenseKeyId: parseInt(
|
||||
subscription.metadata.licenseKeyId
|
||||
)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!invalidateResponse.ok) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features";
|
||||
import {
|
||||
getFeatureIdByMetricId,
|
||||
getFeatureIdByPriceId
|
||||
} from "@server/lib/billing/features";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { getSubType, SubscriptionType } from "./getSubType";
|
||||
@@ -66,7 +69,8 @@ export async function handleSubscriptionUpdated(
|
||||
.limit(1);
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
const previousType = existingSubscription.type as SubscriptionType | null;
|
||||
const previousType =
|
||||
existingSubscription.type as SubscriptionType | null;
|
||||
|
||||
// If the subscription has been manually overridden, we lock the
|
||||
// status down so Stripe webhooks can no longer change it.
|
||||
@@ -100,7 +104,11 @@ export async function handleSubscriptionUpdated(
|
||||
logger.info(
|
||||
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
|
||||
);
|
||||
await handleTierChange(customer.orgId, type, previousType ?? undefined);
|
||||
await handleTierChange(
|
||||
customer.orgId,
|
||||
type,
|
||||
previousType ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert subscription items
|
||||
@@ -113,7 +121,8 @@ export async function handleSubscriptionUpdated(
|
||||
|
||||
const itemsToUpsert = fullSubscription.items.data.map((item) => {
|
||||
// Try to get featureId from price
|
||||
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null;
|
||||
let featureId: string | null =
|
||||
getFeatureIdByPriceId(item.price.id) || null;
|
||||
|
||||
// If no match, try to preserve existing featureId
|
||||
if (!featureId) {
|
||||
@@ -302,14 +311,20 @@ export async function handleSubscriptionUpdated(
|
||||
logger.info(
|
||||
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
|
||||
);
|
||||
await handleTierChange(customer.orgId, null, previousType ?? undefined);
|
||||
await handleTierChange(
|
||||
customer.orgId,
|
||||
null,
|
||||
previousType ?? undefined
|
||||
);
|
||||
}
|
||||
} else if (type === "license") {
|
||||
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
|
||||
if (
|
||||
effectiveStatus === "canceled" ||
|
||||
effectiveStatus == "unpaid" ||
|
||||
effectiveStatus == "incomplete_expired"
|
||||
) {
|
||||
try {
|
||||
// WARNING:
|
||||
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||
await fetch(
|
||||
const invalidateResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -320,16 +335,95 @@ export async function handleSubscriptionUpdated(
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orgId: customer.orgId
|
||||
orgId: customer.orgId,
|
||||
licenseKeyId: parseInt(
|
||||
subscription.metadata.licenseKeyId
|
||||
)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!invalidateResponse.ok) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else if (effectiveStatus === "active" && previousAttributes) {
|
||||
// Detect a successful renewal: the billing period rolled
|
||||
// forward (the invoice was paid and the new period began
|
||||
// right where the previous one ended).
|
||||
const currentItem = fullSubscription.items.data[0];
|
||||
const prevItems = previousAttributes.items?.data;
|
||||
const prevItem = Array.isArray(prevItems)
|
||||
? prevItems.find(
|
||||
(pi: any) => pi.id === currentItem?.id
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const renewed =
|
||||
currentItem &&
|
||||
prevItem?.current_period_end &&
|
||||
currentItem.current_period_start ===
|
||||
prevItem.current_period_end &&
|
||||
currentItem.current_period_start >
|
||||
prevItem.current_period_start;
|
||||
|
||||
if (renewed) {
|
||||
const licenseKeyId =
|
||||
subscription.metadata.licenseKeyId;
|
||||
|
||||
if (!licenseKeyId) {
|
||||
logger.error(
|
||||
`No licenseKeyId in metadata for subscription ${subscription.id}, cannot extend license.`
|
||||
);
|
||||
} else {
|
||||
// Grace period of 5 days added on top of the new
|
||||
// billing period end (usually ~1 year out)
|
||||
const expiresAt =
|
||||
currentItem.current_period_end +
|
||||
5 * 24 * 60 * 60;
|
||||
|
||||
try {
|
||||
const extendResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/extend`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key":
|
||||
privateConfig.getRawPrivateConfig()
|
||||
.server.fossorial_api_key!,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseId: parseInt(licenseKeyId),
|
||||
expiresAt: expiresAt
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!extendResponse.ok) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${extendResponse.status} when extending license ${licenseKeyId} for subscription ${subscription.id}: ${await extendResponse.text()}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`Extended license ${licenseKeyId} for subscription ${subscription.id} to expire at ${expiresAt}.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license renewal for subscription ${subscription.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,14 +64,11 @@ export async function generateNewEnterpriseLicense(
|
||||
|
||||
const licenseData = req.body;
|
||||
|
||||
if (
|
||||
licenseData.tier != "big_license" &&
|
||||
licenseData.tier != "small_license"
|
||||
) {
|
||||
if (licenseData.tier != "tier2" && licenseData.tier != "tier1") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid tier specified. Must be either 'big_license' or 'small_license'."
|
||||
"Invalid tier specified. Must be either 'tier2' or 'tier1'."
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -99,6 +96,8 @@ export async function generateNewEnterpriseLicense(
|
||||
);
|
||||
}
|
||||
|
||||
const licenseKeyValue = apiResponse?.data?.licenseKey?.licenseKey;
|
||||
|
||||
// check if we already have a customer for this org
|
||||
const [customer] = await db
|
||||
.select()
|
||||
@@ -118,9 +117,7 @@ export async function generateNewEnterpriseLicense(
|
||||
}
|
||||
|
||||
const tier =
|
||||
licenseData.tier === "big_license"
|
||||
? LicenseId.BIG_LICENSE
|
||||
: LicenseId.SMALL_LICENSE;
|
||||
licenseData.tier === "tier2" ? LicenseId.TIER2 : LicenseId.TIER1;
|
||||
const tierPrice = getLicensePriceSet()[tier];
|
||||
|
||||
const session = await stripe!.checkout.sessions.create({
|
||||
@@ -134,6 +131,16 @@ export async function generateNewEnterpriseLicense(
|
||||
], // Start with the standard feature set that matches the free limits
|
||||
customer: customer.customerId,
|
||||
mode: "subscription",
|
||||
subscription_data: {
|
||||
description: licenseKeyValue
|
||||
? `License ${licenseKeyValue}`
|
||||
: `License key ID ${keyId}`,
|
||||
metadata: {
|
||||
licenseKeyId: keyId.toString(),
|
||||
licenseKey: licenseKeyValue ?? "",
|
||||
tier: licenseData.tier
|
||||
}
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
|
||||
|
||||
@@ -47,7 +47,6 @@ export const queryAiSessionLogsQuery = z.strictObject({
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
|
||||
@@ -20,7 +20,6 @@ const queryAccessAuditLogsQuery = z.object({
|
||||
error: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => getSevenDaysAgo().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
@@ -34,7 +33,6 @@ const queryAccessAuditLogsQuery = z.object({
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
|
||||
@@ -41,7 +41,6 @@ export const queryAccessAuditLogsQuery = z.strictObject({
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import type {
|
||||
GenerateRegistrationOptionsOpts,
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
AuthenticatorTransportFuture
|
||||
AuthenticatorTransport
|
||||
} from "@simplewebauthn/server";
|
||||
import { isoBase64URL } from "@simplewebauthn/server/helpers";
|
||||
import config from "@server/lib/config";
|
||||
@@ -221,7 +221,7 @@ export async function startRegistration(
|
||||
const excludeCredentials = existingSecurityKeys.map((key) => ({
|
||||
id: key.credentialId,
|
||||
transports: key.transports
|
||||
? (JSON.parse(key.transports) as AuthenticatorTransportFuture[])
|
||||
? (JSON.parse(key.transports) as AuthenticatorTransport[])
|
||||
: undefined
|
||||
}));
|
||||
|
||||
@@ -571,7 +571,7 @@ export async function startAuthentication(
|
||||
transports: key.transports
|
||||
? (JSON.parse(
|
||||
key.transports
|
||||
) as AuthenticatorTransportFuture[])
|
||||
) as AuthenticatorTransport[])
|
||||
: undefined
|
||||
}));
|
||||
}
|
||||
@@ -702,7 +702,7 @@ export async function verifyAuthentication(
|
||||
transports: securityKey.transports
|
||||
? (JSON.parse(
|
||||
securityKey.transports
|
||||
) as AuthenticatorTransportFuture[])
|
||||
) as AuthenticatorTransport[])
|
||||
: undefined
|
||||
},
|
||||
requireUserVerification: false
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
|
||||
// Error codes for registration failures
|
||||
export const NewtErrorCodes = {
|
||||
NO_AVAILABLE_SUBNET: {
|
||||
code: "NO_AVAILABLE_SUBNET",
|
||||
message:
|
||||
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Helper function to send registration error
|
||||
export async function sendNewtError(
|
||||
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
|
||||
newtId: string
|
||||
) {
|
||||
sendToClient(newtId, {
|
||||
type: "newt/error",
|
||||
data: {
|
||||
code: error.code,
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
import { fetchContainers } from "./dockerSocket";
|
||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import { NewtErrorCodes, sendNewtError } from "./error";
|
||||
|
||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
@@ -116,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
logger.error(
|
||||
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
|
||||
);
|
||||
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,11 @@ export const OlmErrorCodes = {
|
||||
HOLEPUNCH_MISSING: {
|
||||
code: "HOLEPUNCH_MISSING",
|
||||
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
|
||||
},
|
||||
NO_AVAILABLE_SUBNET: {
|
||||
code: "NO_AVAILABLE_SUBNET",
|
||||
message:
|
||||
"No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
const { message, client: c } = context;
|
||||
const newt = c as Newt;
|
||||
|
||||
logger.info("Handling healthcheck status message");
|
||||
logger.debug("Handling healthcheck status message");
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Newt not found");
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "path";
|
||||
import semver from "semver";
|
||||
import { versionMigrations } from "../db/sqlite";
|
||||
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
|
||||
import { formatBackupTimestamp } from "@server/lib/backupFileName";
|
||||
import { SqliteError } from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import { build } from "@server/build";
|
||||
@@ -121,7 +122,7 @@ function backupDb() {
|
||||
// copy the db.sqlite file to backups
|
||||
// add the date to the filename
|
||||
const date = new Date();
|
||||
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
|
||||
const dateString = formatBackupTimestamp(date);
|
||||
const dbPath = path.join(dbDir, "db.sqlite");
|
||||
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
|
||||
@@ -3,8 +3,6 @@ import { sql } from "drizzle-orm";
|
||||
|
||||
const version = "1.23.0";
|
||||
|
||||
await migration();
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
@@ -12,11 +10,11 @@ export default async function migration() {
|
||||
await db.execute(sql`BEGIN`);
|
||||
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "newt" ADD COLUMN "agent" varchar;
|
||||
ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agent" varchar;
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "newt" ADD COLUMN "agentVersion" varchar;
|
||||
ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agentVersion" varchar;
|
||||
`);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
users
|
||||
} from "../../db/sqlite";
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { formatBackupTimestamp } from "@server/lib/backupFileName";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
@@ -34,7 +35,7 @@ export default async function migration() {
|
||||
// copy the db.sqlite file to backups
|
||||
// add the date to the filename
|
||||
const date = new Date();
|
||||
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
|
||||
const dateString = formatBackupTimestamp(date);
|
||||
const dbPath = path.join(dbDir, "db.sqlite");
|
||||
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
|
||||
@@ -16,13 +16,13 @@ export default async function migration() {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'newt' ADD 'agent' text;
|
||||
ALTER TABLE 'newt' ADD COLUMN 'agent' text;
|
||||
`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'newt' ADD 'agentVersion' text;
|
||||
ALTER TABLE 'newt' ADD COLUMN 'agentVersion' text;
|
||||
`
|
||||
).run();
|
||||
})();
|
||||
|
||||
@@ -749,12 +749,6 @@ export default function BillingPage() {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Get license key count
|
||||
const getLicenseKeyCount = (): number => {
|
||||
if (!licenseSubscription?.items) return 0;
|
||||
return licenseSubscription.items.length;
|
||||
};
|
||||
|
||||
// Check if downgrading to a tier would violate current usage limits
|
||||
const checkLimitViolations = (
|
||||
targetTier: Tier | "basic"
|
||||
@@ -1545,7 +1539,7 @@ export default function BillingPage() {
|
||||
</SettingsSection>
|
||||
|
||||
{/* Paid License Keys Section */}
|
||||
{(licenseSubscription || getLicenseKeyCount() > 0) && (
|
||||
{licenseSubscription && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
@@ -1561,22 +1555,6 @@ export default function BillingPage() {
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{t("billingCurrentKeys") ||
|
||||
"Current Keys"}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-semibold">
|
||||
{getLicenseKeyCount()}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{getLicenseKeyCount() === 1
|
||||
? "key"
|
||||
: "keys"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleModifySubscription}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
|
||||
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
|
||||
@@ -26,5 +27,16 @@ export default async function Page({ params }: Props) {
|
||||
licenseKeys = data.data.data;
|
||||
} catch {}
|
||||
|
||||
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />;
|
||||
const hasNonPersonalLicenseKey = licenseKeys.some(
|
||||
(key) => key.tier !== "personal"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasNonPersonalLicenseKey && (
|
||||
<LicenseBillingBanner orgId={orgId} />
|
||||
)}
|
||||
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,11 +76,13 @@ export default function PrivateResourceInferencePage() {
|
||||
})
|
||||
),
|
||||
httpConfigSubdomain: z.string().nullish(),
|
||||
httpConfigDomainId: z.string().nullish(),
|
||||
httpConfigDomainId: z
|
||||
.string()
|
||||
.min(1, { message: t("domainRequired") }),
|
||||
httpConfigFullDomain: z.string().nullish(),
|
||||
ssl: z.boolean().optional()
|
||||
}),
|
||||
[]
|
||||
[t]
|
||||
);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -103,7 +105,7 @@ export default function PrivateResourceInferencePage() {
|
||||
defaultValues: {
|
||||
providers: [],
|
||||
httpConfigSubdomain: siteResource.subdomain ?? null,
|
||||
httpConfigDomainId: siteResource.domainId ?? null,
|
||||
httpConfigDomainId: siteResource.domainId ?? "",
|
||||
httpConfigFullDomain: siteResource.fullDomain ?? null,
|
||||
ssl: siteResource.ssl ?? false
|
||||
}
|
||||
@@ -289,22 +291,33 @@ export default function PrivateResourceInferencePage() {
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="httpConfigDomainId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
key={`inference-domain-${siteResource.id}`}
|
||||
orgId={siteResource.orgId}
|
||||
orgId={
|
||||
siteResource.orgId
|
||||
}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
httpConfigSubdomain ??
|
||||
undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
httpConfigDomainId ??
|
||||
undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ??
|
||||
undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
if (res === null) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
@@ -312,7 +325,11 @@ export default function PrivateResourceInferencePage() {
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
null
|
||||
"",
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
@@ -322,11 +339,16 @@ export default function PrivateResourceInferencePage() {
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
res.subdomain ??
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
res.domainId,
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
@@ -334,6 +356,10 @@ export default function PrivateResourceInferencePage() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
|
||||
@@ -139,6 +139,22 @@ export default function GeneralForm() {
|
||||
: "Port number should not be set for HTTP resources",
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (
|
||||
["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resource.mode
|
||||
)
|
||||
) {
|
||||
return !!data.domainId;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("domainRequired"),
|
||||
path: ["domainId"]
|
||||
}
|
||||
);
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
@@ -434,6 +450,11 @@ export default function GeneralForm() {
|
||||
resource.mode
|
||||
) && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domainId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<div id="resource-domain-picker">
|
||||
<DomainPicker
|
||||
allowWildcard={
|
||||
@@ -443,17 +464,21 @@ export default function GeneralForm() {
|
||||
key={
|
||||
resource.resourceId
|
||||
}
|
||||
orgId={orgId as string}
|
||||
orgId={
|
||||
orgId as string
|
||||
}
|
||||
cols={2}
|
||||
defaultSubdomain={
|
||||
form.watch(
|
||||
"subdomain"
|
||||
) ?? undefined
|
||||
) ??
|
||||
undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
form.watch(
|
||||
"domainId"
|
||||
) ?? undefined
|
||||
) ??
|
||||
undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
resourceFullDomainName ||
|
||||
@@ -462,10 +487,17 @@ export default function GeneralForm() {
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
if (res === null) {
|
||||
if (
|
||||
res ===
|
||||
null
|
||||
) {
|
||||
form.setValue(
|
||||
"domainId",
|
||||
undefined
|
||||
undefined,
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
@@ -478,7 +510,11 @@ export default function GeneralForm() {
|
||||
}
|
||||
form.setValue(
|
||||
"domainId",
|
||||
res.domainId
|
||||
res.domainId,
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
@@ -491,6 +527,10 @@ export default function GeneralForm() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
{!["tcp", "udp", "inference"].includes(
|
||||
|
||||
@@ -252,9 +252,10 @@ export default function GenerateLicenseKeyForm({
|
||||
try {
|
||||
// Check if this is a business/enterprise license request
|
||||
if (payload.useCaseType === "business") {
|
||||
const response = await api.put<
|
||||
AxiosResponse<string>
|
||||
>(`/org/${orgId}/license/enterprise`, { ...payload, tier: "big_license" } );
|
||||
const response = await api.put<AxiosResponse<string>>(
|
||||
`/org/${orgId}/license/enterprise`,
|
||||
{ ...payload, tier: "tier2" }
|
||||
);
|
||||
|
||||
console.log("Checkout session response:", response.data);
|
||||
const checkoutUrl = response.data.data;
|
||||
|
||||
@@ -201,9 +201,16 @@ export default function GenerateLicenseKeysTable({
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const tier = row.original.tier;
|
||||
return tier === "enterprise"
|
||||
? t("licenseTierEnterprise")
|
||||
: t("licenseTierPersonal");
|
||||
switch (tier) {
|
||||
case "enterprise":
|
||||
return t("licenseTierEnterprise");
|
||||
case "tier1":
|
||||
return t("licenseTierTier1");
|
||||
case "tier2":
|
||||
return t("licenseTierTier2");
|
||||
default:
|
||||
return t("licenseTierPersonal");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Globe, CreditCard, ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
type LicenseBillingBannerProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export const LicenseBillingBanner = ({ orgId }: LicenseBillingBannerProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="license-billing-banner-dismissed"
|
||||
version={1}
|
||||
title={t("licenseBillingBannerTitle")}
|
||||
titleIcon={<Globe className="w-5 h-5 text-primary" />}
|
||||
description={t("licenseBillingBannerDescription")}
|
||||
>
|
||||
<Link href={`/${orgId}/settings/billing`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{t("licenseBillingBannerButton")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseBillingBanner;
|
||||
@@ -100,9 +100,16 @@ export function LicenseKeysDataTable({
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const tier = row.original.tier;
|
||||
return tier === "enterprise"
|
||||
? t("licenseTierEnterprise")
|
||||
: t("licenseTierPersonal");
|
||||
switch (tier) {
|
||||
case "enterprise":
|
||||
return t("licenseTierEnterprise");
|
||||
case "tier1":
|
||||
return t("licenseTierTier1");
|
||||
case "tier2":
|
||||
return t("licenseTierTier2");
|
||||
default:
|
||||
return t("licenseTierPersonal");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,8 +40,8 @@ import { InfoIcon } from "lucide-react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
const TIER_TO_LICENSE_ID = {
|
||||
starter: "small_license",
|
||||
scale: "big_license"
|
||||
starter: "tier1",
|
||||
scale: "tier2"
|
||||
} as const;
|
||||
|
||||
type FormProps = {
|
||||
@@ -330,7 +330,7 @@ export default function NewPricingLicenseForm({
|
||||
cols={2}
|
||||
/>
|
||||
<a
|
||||
href="https://pangolin.net/pricing"
|
||||
href="https://pangolin.net/pricing#Self-Hosted"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
|
||||
@@ -63,6 +63,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
) : null;
|
||||
|
||||
if (site.type === "newt") {
|
||||
// agent and agentVersion were added after newtVersion, so a
|
||||
// site still running an older Newt reports only newtVersion.
|
||||
// Without these fallbacks the badge renders with no label and
|
||||
// no version at all.
|
||||
const agentLabel = site.agent == "cli" ? "Pangolin CLI" : "Newt";
|
||||
const agentVersion = site.agentVersion ?? site.newtVersion;
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -79,13 +85,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("agent")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.agent == "newt" ? "Newt" : null}
|
||||
{site.agent == "cli"
|
||||
? "Pangolin CLI"
|
||||
: null}{" "}
|
||||
{site.agentVersion
|
||||
? `v${site.agentVersion}`
|
||||
: "-"}
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{agentLabel}</span>
|
||||
{agentVersion && (
|
||||
<span>v{agentVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{endpointSection}
|
||||
|
||||
@@ -373,7 +373,7 @@ export default function SitesTable({
|
||||
accessorKey: "type",
|
||||
friendlyName: t("agent"),
|
||||
header: () => {
|
||||
return <span className="p-3">{t("type")}</span>;
|
||||
return <span className="p-3">{t("agent")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
@@ -386,25 +386,27 @@ export default function SitesTable({
|
||||
);
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
if (!originalRow.agent) {
|
||||
if (!originalRow.agent && !originalRow.newtVersion) {
|
||||
// it has not checked in yet
|
||||
return <span>-</span>;
|
||||
}
|
||||
// agent and agentVersion were added after newtVersion, so a
|
||||
// site still running an older Newt reports only newtVersion.
|
||||
// Without these fallbacks the badge renders with no label and
|
||||
// no version at all.
|
||||
const agentLabel =
|
||||
originalRow.agent == "cli"
|
||||
? "Pangolin CLI"
|
||||
: "Newt";
|
||||
const agentVersion =
|
||||
originalRow.agentVersion ?? originalRow.newtVersion;
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>
|
||||
{originalRow.agent == "newt"
|
||||
? "Newt"
|
||||
: null}
|
||||
{originalRow.agent == "cli"
|
||||
? "Pangolin CLI"
|
||||
: null}
|
||||
</span>
|
||||
{originalRow.agentVersion && (
|
||||
<span>
|
||||
v{originalRow.agentVersion}
|
||||
</span>
|
||||
<span>{agentLabel}</span>
|
||||
{agentVersion && (
|
||||
<span>v{agentVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
|
||||
@@ -150,7 +150,7 @@ Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/etc/pangolin/pangolin-site.env
|
||||
ExecStart=/home/owen/fossorial/cli/bin/pangolin up site
|
||||
ExecStart=/usr/local/bin/pangolin up site
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
UMask=0077
|
||||
|
||||
@@ -87,7 +87,7 @@ function Calendar({
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
|
||||
@@ -523,6 +523,13 @@ export function createCreateFormSchema(t: TranslateFn) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.mode === "inference" && !data.httpConfigDomainId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("domainRequired"),
|
||||
path: ["httpConfigDomainId"]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user