diff --git a/.github/workflows/saas.yml b/.github/workflows/saas.yml
index 5db7aa2f..93e5d198 100644
--- a/.github/workflows/saas.yml
+++ b/.github/workflows/saas.yml
@@ -56,6 +56,41 @@ jobs:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - name: Download MaxMind GeoLite2 databases
+ env:
+ MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
+ run: |
+ echo "Downloading MaxMind GeoLite2 databases..."
+
+ # Download GeoLite2-Country
+ curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
+ -o GeoLite2-Country.tar.gz
+
+ # Download GeoLite2-ASN
+ curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
+ -o GeoLite2-ASN.tar.gz
+
+ # Extract the .mmdb files
+ tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
+ tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
+
+ # Verify files exist
+ if [ ! -f "GeoLite2-Country.mmdb" ]; then
+ echo "ERROR: Failed to download GeoLite2-Country.mmdb"
+ exit 1
+ fi
+
+ if [ ! -f "GeoLite2-ASN.mmdb" ]; then
+ echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
+ exit 1
+ fi
+
+ # Clean up tar files
+ rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
+
+ echo "MaxMind databases downloaded successfully"
+ ls -lh GeoLite2-*.mmdb
+
- name: Monitor storage space
run: |
THRESHOLD=75
diff --git a/Dockerfile b/Dockerfile
index 4830067e..12c519b7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -49,6 +49,14 @@ COPY server/db/ios_models.json ./dist/ios_models.json
COPY server/db/mac_models.json ./dist/mac_models.json
COPY public ./public
+# Copy MaxMind databases for SaaS builds
+ARG BUILD=oss
+RUN mkdir -p ./maxmind
+
+# This is only for saas
+COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb
+COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb
+
# OCI Image Labels - Build Args for dynamic values
ARG VERSION="dev"
ARG REVISION=""
diff --git a/cli/commands/generateOrgCaKeys.ts b/cli/commands/generateOrgCaKeys.ts
new file mode 100644
index 00000000..af822c81
--- /dev/null
+++ b/cli/commands/generateOrgCaKeys.ts
@@ -0,0 +1,121 @@
+import { CommandModule } from "yargs";
+import { db, orgs } from "@server/db";
+import { eq } from "drizzle-orm";
+import { encrypt } from "@server/lib/crypto";
+import { configFilePath1, configFilePath2 } from "@server/lib/consts";
+import { generateCA } from "@server/private/lib/sshCA";
+import fs from "fs";
+import yaml from "js-yaml";
+
+type GenerateOrgCaKeysArgs = {
+ orgId: string;
+ secret?: string;
+ force?: boolean;
+};
+
+export const generateOrgCaKeys: CommandModule<{}, GenerateOrgCaKeysArgs> = {
+ command: "generate-org-ca-keys",
+ describe:
+ "Generate SSH CA public/private key pair for an organization and store them in the database (private key encrypted with server secret)",
+ builder: (yargs) => {
+ return yargs
+ .option("orgId", {
+ type: "string",
+ demandOption: true,
+ describe: "The organization ID"
+ })
+ .option("secret", {
+ type: "string",
+ describe:
+ "Server secret used to encrypt the CA private key. If omitted, read from config file (config.yml or config.yaml)."
+ })
+ .option("force", {
+ type: "boolean",
+ default: false,
+ describe:
+ "Overwrite existing CA keys for the org if they already exist"
+ });
+ },
+ handler: async (argv: {
+ orgId: string;
+ secret?: string;
+ force?: boolean;
+ }) => {
+ try {
+ const { orgId, force } = argv;
+ let secret = argv.secret;
+
+ if (!secret) {
+ const configPath = fs.existsSync(configFilePath1)
+ ? configFilePath1
+ : fs.existsSync(configFilePath2)
+ ? configFilePath2
+ : null;
+
+ if (!configPath) {
+ console.error(
+ "Error: No server secret provided and config file not found. " +
+ "Expected config.yml or config.yaml in the config directory, or pass --secret."
+ );
+ process.exit(1);
+ }
+
+ const configContent = fs.readFileSync(configPath, "utf8");
+ const config = yaml.load(configContent) as {
+ server?: { secret?: string };
+ };
+
+ if (!config?.server?.secret) {
+ console.error(
+ "Error: No server.secret in config file. Pass --secret or set server.secret in config."
+ );
+ process.exit(1);
+ }
+ secret = config.server.secret;
+ }
+
+ const [org] = await db
+ .select({
+ orgId: orgs.orgId,
+ sshCaPrivateKey: orgs.sshCaPrivateKey,
+ sshCaPublicKey: orgs.sshCaPublicKey
+ })
+ .from(orgs)
+ .where(eq(orgs.orgId, orgId))
+ .limit(1);
+
+ if (!org) {
+ console.error(`Error: Organization with orgId "${orgId}" not found.`);
+ process.exit(1);
+ }
+
+ if (org.sshCaPrivateKey != null || org.sshCaPublicKey != null) {
+ if (!force) {
+ console.error(
+ "Error: This organization already has CA keys. Use --force to overwrite."
+ );
+ process.exit(1);
+ }
+ }
+
+ const ca = generateCA(`pangolin-ssh-ca-${orgId}`);
+ const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret);
+
+ await db
+ .update(orgs)
+ .set({
+ sshCaPrivateKey: encryptedPrivateKey,
+ sshCaPublicKey: ca.publicKeyOpenSSH
+ })
+ .where(eq(orgs.orgId, orgId));
+
+ console.log("SSH CA keys generated and stored for org:", orgId);
+ console.log("\nPublic key (OpenSSH format):");
+ console.log(ca.publicKeyOpenSSH);
+ process.exit(0);
+ } catch (error) {
+ console.error("Error generating org CA keys:", error);
+ process.exit(1);
+ }
+ }
+};
diff --git a/cli/index.ts b/cli/index.ts
index d517064c..7605904e 100644
--- a/cli/index.ts
+++ b/cli/index.ts
@@ -8,6 +8,7 @@ import { clearExitNodes } from "./commands/clearExitNodes";
import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient";
+import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
yargs(hideBin(process.argv))
.scriptName("pangctl")
@@ -17,5 +18,6 @@ yargs(hideBin(process.argv))
.command(rotateServerSecret)
.command(clearLicenseKeys)
.command(deleteClient)
+ .command(generateOrgCaKeys)
.demandCommand()
.help().argv;
diff --git a/messages/bg-BG.json b/messages/bg-BG.json
index b8cd3893..60e4b401 100644
--- a/messages/bg-BG.json
+++ b/messages/bg-BG.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Ролята е премахната",
"accessRoleRemovedDescription": "Ролята беше успешно премахната.",
"accessRoleRequiredRemove": "Преди да изтриете тази роля, моля изберете нова роля, към която да прехвърлите настоящите членове.",
+ "network": "Мрежа",
"manage": "Управление",
"sitesNotFound": "Няма намерени сайтове.",
"pangolinServerAdmin": "Администратор на сървър - Панголин",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Частно",
"sidebarAccessControl": "Контрол на достъпа",
"sidebarLogsAndAnalytics": "Дневници и анализи",
+ "sidebarTeam": "Екип",
"sidebarUsers": "Потребители",
"sidebarAdmin": "Администратор",
"sidebarInvitations": "Покани",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Лог & Анализи",
"sidebarBluePrints": "Чертежи",
"sidebarOrganization": "Организация",
+ "sidebarManagement": "Управление",
"sidebarBillingAndLicenses": "Фактуриране & Лицензи",
"sidebarLogsAnalytics": "Анализи",
"blueprints": "Чертежи",
@@ -1289,7 +1292,6 @@
"parsedContents": "Парсирано съдържание (само за четене)",
"enableDockerSocket": "Активиране на Docker Чернова",
"enableDockerSocketDescription": "Активиране на Docker Socket маркировка за изтегляне на етикети на чернова. Пътят на гнездото трябва да бъде предоставен на Newt.",
- "enableDockerSocketLink": "Научете повече",
"viewDockerContainers": "Преглед на Docker контейнери",
"containersIn": "Контейнери в {siteName}",
"selectContainerDescription": "Изберете контейнер, който да ползвате като име на хост за целта. Натиснете порт, за да ползвате порт",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Уведомление за наличност на функциите",
"billingFeatureLossDescription": "Чрез понижението на плана, функциите, недостъпни в новия план, ще бъдат автоматично деактивирани. Някои настройки и конфигурации може да бъдат загубени. Моля, прегледайте ценовата матрица, за да разберете кои функции вече няма да са на разположение.",
"billingUsageExceedsLimit": "Текущото използване ({current}) надвишава ограничението ({limit})",
+ "billingPastDueTitle": "Плащането е просрочено",
+ "billingPastDueDescription": "Вашето плащане е просрочено. Моля, актуализирайте метода на плащане, за да продължите да използвате настоящия си план. Ако проблемът не бъде разрешен, абонаментът ви ще бъде прекратен и ще бъдете прехвърлени на безплатния план.",
+ "billingUnpaidTitle": "Абонаментът не е платен",
+ "billingUnpaidDescription": "Вашият абонамент не е платен и сте прехвърлени на безплатния план. Моля, актуализирайте метода на плащане, за да възстановите вашия абонамент.",
+ "billingIncompleteTitle": "Плащането е непълно",
+ "billingIncompleteDescription": "Вашето плащане е непълно. Моля, завършете процеса на плащане, за да активирате вашия абонамент.",
+ "billingIncompleteExpiredTitle": "Плащането е изтекло",
+ "billingIncompleteExpiredDescription": "Вашето плащане никога не е завършено и е изтекло. Прехвърлени сте на безплатния план. Моля, абонирайте се отново, за да възстановите достъпа до платените функции.",
+ "billingManageSubscription": "Управлявайте вашия абонамент",
+ "billingResolvePaymentIssue": "Моля, разрешете проблема с плащането преди да извършите надграждане или понижение",
"signUpTerms": {
"IAgreeToThe": "Съгласен съм с",
"termsOfService": "условията за ползване",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Времето е в секунди",
"requireDeviceApproval": "Изискват одобрение на устройства",
"requireDeviceApprovalDescription": "Потребители с тази роля трябва да имат нови устройства одобрени от администратор преди да могат да се свържат и да имат достъп до ресурси.",
+ "sshAccess": "SSH достъп",
+ "roleAllowSsh": "Разреши SSH",
+ "roleAllowSshAllow": "Разреши",
+ "roleAllowSshDisallow": "Забрани",
+ "roleAllowSshDescription": "Разреши на потребителите с тази роля да се свързват с ресурси чрез SSH. Когато е деактивирано, ролята не може да използва SSH достъп.",
+ "sshSudoMode": "Sudo достъп",
+ "sshSudoModeNone": "Няма",
+ "sshSudoModeNoneDescription": "Потребителят не може да изпълнява команди с sudo.",
+ "sshSudoModeFull": "Пълен Sudo",
+ "sshSudoModeFullDescription": "Потребителят може да изпълнява всяка команда с sudo.",
+ "sshSudoModeCommands": "Команди",
+ "sshSudoModeCommandsDescription": "Потребителят може да изпълнява само определени команди с sudo.",
+ "sshSudo": "Разреши sudo",
+ "sshSudoCommands": "Sudo команди",
+ "sshSudoCommandsDescription": "Списък с команди, които потребителят е разрешено да изпълнява с sudo.",
+ "sshCreateHomeDir": "Създай начална директория",
+ "sshUnixGroups": "Unix групи",
+ "sshUnixGroupsDescription": "Unix групи, в които да добавите потребителя на целевия хост.",
"retryAttempts": "Опити за повторно",
"expectedResponseCodes": "Очаквани кодове за отговор",
"expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Контрол на достъпа.",
"editInternalResourceDialogAccessControlDescription": "Контролирайте кои роли, потребители и клиентски машини имат достъп до този ресурс, когато са свързани. Администраторите винаги имат достъп.",
"editInternalResourceDialogPortRangeValidationError": "Обхватът на портовете трябва да е \"*\" за всички портове или списък от разделени със запетая портове и диапазони (например: \"80,443,8000-9000\"). Портовете трябва да са между 1 и 65535.",
+ "internalResourceAuthDaemonStrategy": "Локация на SSH Auth Daemon",
+ "internalResourceAuthDaemonStrategyDescription": "Изберете къде ще работи демонът за SSH удостоверение: на сайта (Newt) или на отдалечен хост.",
+ "internalResourceAuthDaemonDescription": "Демонът за SSH удостоверение управлява подписването на SSH ключове и PAM удостоверение за този ресурс. Изберете дали да работи на сайта (Newt) или на отделен отдалечен хост. Вижте документацията за повече информация.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Изберете стратегия",
+ "internalResourceAuthDaemonStrategyLabel": "Местоположение",
+ "internalResourceAuthDaemonSite": "На сайта",
+ "internalResourceAuthDaemonSiteDescription": "Демонът за удостоверение работи на сайта (Newt).",
+ "internalResourceAuthDaemonRemote": "Отдалечен хост",
+ "internalResourceAuthDaemonRemoteDescription": "Демонът за удостоверение работи на хост, който не е сайтът.",
+ "internalResourceAuthDaemonPort": "Порт на демона (незадължителен)",
"orgAuthWhatsThis": "Къде мога да намеря идентификатора на организацията си?",
"learnMore": "Научете повече.",
"backToHome": "Връщане към началната страница.",
diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json
index e4ff7269..b7666db1 100644
--- a/messages/cs-CZ.json
+++ b/messages/cs-CZ.json
@@ -336,7 +336,7 @@
"userQuestionRemove": "Jste si jisti, že chcete trvale odstranit uživatele ze serveru?",
"licenseKey": "Licenční klíč",
"valid": "Valid",
- "numberOfSites": "Počet stránek",
+ "numberOfSites": "Počet lokalit",
"licenseKeySearch": "Hledat licenční klíče...",
"licenseKeyAdd": "Přidat licenční klíč",
"type": "Typ",
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Role odstraněna",
"accessRoleRemovedDescription": "Role byla úspěšně odstraněna.",
"accessRoleRequiredRemove": "Před odstraněním této role vyberte novou roli, do které chcete převést existující členy.",
+ "network": "Síť",
"manage": "Spravovat",
"sitesNotFound": "Nebyly nalezeny žádné stránky.",
"pangolinServerAdmin": "Správce serveru - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Soukromé",
"sidebarAccessControl": "Kontrola přístupu",
"sidebarLogsAndAnalytics": "Logy & Analytika",
+ "sidebarTeam": "Tým",
"sidebarUsers": "Uživatelé",
"sidebarAdmin": "Admin",
"sidebarInvitations": "Pozvánky",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Plány",
"sidebarOrganization": "Organizace",
+ "sidebarManagement": "Správa",
"sidebarBillingAndLicenses": "Fakturace a licence",
"sidebarLogsAnalytics": "Analytici",
"blueprints": "Plány",
@@ -1289,7 +1292,6 @@
"parsedContents": "Parsed content (Pouze pro čtení)",
"enableDockerSocket": "Povolit Docker plán",
"enableDockerSocketDescription": "Povolte seškrábání štítků na Docker Socket pro popisky plánů. Nová cesta musí být k dispozici.",
- "enableDockerSocketLink": "Zjistit více",
"viewDockerContainers": "Zobrazit kontejnery Dockeru",
"containersIn": "Kontejnery v {siteName}",
"selectContainerDescription": "Vyberte jakýkoli kontejner pro použití jako název hostitele pro tento cíl. Klikněte na port pro použití portu.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Upozornění na dostupnost funkce",
"billingFeatureLossDescription": "Po pomenutí budou funkce v novém plánu automaticky zakázány. Některá nastavení a konfigurace mohou být ztraceny. Zkontrolujte cenovou matrici, abyste pochopili, které funkce již nebudou k dispozici.",
"billingUsageExceedsLimit": "Aktuální využití ({current}) překračuje limit ({limit})",
+ "billingPastDueTitle": "Poslední splatnost platby",
+ "billingPastDueDescription": "Vaše platba je již splatná. Chcete-li pokračovat v používání aktuálních tarifů, aktualizujte prosím způsob platby. Pokud nebude vyřešeno, Vaše předplatné bude zrušeno a budete vráceno na úroveň zdarma.",
+ "billingUnpaidTitle": "Předplatné nezaplaceno",
+ "billingUnpaidDescription": "Vaše předplatné není zaplaceno a byli jste vráceni do bezplatné úrovně. Aktualizujte prosím svou platební metodu pro obnovení předplatného.",
+ "billingIncompleteTitle": "Platba nedokončena",
+ "billingIncompleteDescription": "Vaše platba je neúplná. Pro aktivaci předplatného prosím dokončete platební proces.",
+ "billingIncompleteExpiredTitle": "Platba vypršela",
+ "billingIncompleteExpiredDescription": "Vaše platba nebyla nikdy dokončena a vypršela. Byli jste vráceni na úroveň zdarma. Prosím, přihlašte se znovu pro obnovení přístupu k placeným funkcím.",
+ "billingManageSubscription": "Spravujte své předplatné",
+ "billingResolvePaymentIssue": "Vyřešte prosím problém s platbou před upgradem nebo upgradem",
"signUpTerms": {
"IAgreeToThe": "Souhlasím s",
"termsOfService": "podmínky služby",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Čas je v sekundách",
"requireDeviceApproval": "Vyžadovat schválení zařízení",
"requireDeviceApprovalDescription": "Uživatelé s touto rolí potřebují nová zařízení schválená správcem, než se mohou připojit a přistupovat ke zdrojům.",
+ "sshAccess": "SSH přístup",
+ "roleAllowSsh": "Povolit SSH",
+ "roleAllowSshAllow": "Povolit",
+ "roleAllowSshDisallow": "Zakázat",
+ "roleAllowSshDescription": "Povolit uživatelům s touto rolí připojení k zdrojům přes SSH. Je-li zakázáno, role nemůže používat přístup SSH.",
+ "sshSudoMode": "Súdánský přístup",
+ "sshSudoModeNone": "Nic",
+ "sshSudoModeNoneDescription": "Uživatel nemůže spouštět příkazy se sudo.",
+ "sshSudoModeFull": "Úplný Súdán",
+ "sshSudoModeFullDescription": "Uživatel může spustit libovolný příkaz se sudo.",
+ "sshSudoModeCommands": "Příkazy",
+ "sshSudoModeCommandsDescription": "Uživatel může spustit pouze zadané příkazy s sudo.",
+ "sshSudo": "Povolit sudo",
+ "sshSudoCommands": "Sudo příkazy",
+ "sshSudoCommandsDescription": "Seznam příkazů, které může uživatel spouštět s sudo.",
+ "sshCreateHomeDir": "Vytvořit domovský adresář",
+ "sshUnixGroups": "Unixové skupiny",
+ "sshUnixGroupsDescription": "Unix skupiny přidají uživatele do cílového hostitele.",
"retryAttempts": "Opakovat pokusy",
"expectedResponseCodes": "Očekávané kódy odezvy",
"expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.",
@@ -2362,7 +2392,7 @@
"terms": "Výrazy",
"privacy": "Soukromí",
"security": "Zabezpečení",
- "docs": "Dokumenty",
+ "docs": "Dokumentace",
"deviceActivation": "Aktivace zařízení",
"deviceCodeInvalidFormat": "Kód musí být 9 znaků (např. A1AJ-N5JD)",
"deviceCodeInvalidOrExpired": "Neplatný nebo prošlý kód",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Řízení přístupu",
"editInternalResourceDialogAccessControlDescription": "Kontrolujte, které role, uživatelé a klienti mohou přistupovat k tomuto prostředku, když jsou připojeni. Admini mají vždy přístup.",
"editInternalResourceDialogPortRangeValidationError": "Rozsah portů musí být \"*\" pro všechny porty, nebo seznam portů a rozsahů oddělených čárkou (např. \"80,443,8000-9000\"). Porty musí být mezi 1 a 65535.",
+ "internalResourceAuthDaemonStrategy": "SSH Auth Démon umístění",
+ "internalResourceAuthDaemonStrategyDescription": "Zvolte, kde běží SSH autentizační démon: na stránce (Newt) nebo na vzdáleném serveru.",
+ "internalResourceAuthDaemonDescription": "SSH autentizační daemon zpracovává podpis SSH klíče a PAM autentizaci tohoto zdroje. Vyberte si, zda běží na webu (Newt) nebo na samostatném vzdáleném serveru. Více informací najdete v dokumentaci.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Vybrat strategii",
+ "internalResourceAuthDaemonStrategyLabel": "Poloha",
+ "internalResourceAuthDaemonSite": "Na stránce",
+ "internalResourceAuthDaemonSiteDescription": "Auth daemon běží na webu (Newt).",
+ "internalResourceAuthDaemonRemote": "Vzdálený server",
+ "internalResourceAuthDaemonRemoteDescription": "Auth daemon běží na hostitele, který není web.",
+ "internalResourceAuthDaemonPort": "Daemon port (volitelné)",
"orgAuthWhatsThis": "Kde najdu ID mé organizace?",
"learnMore": "Zjistit více",
"backToHome": "Zpět na domovskou stránku",
diff --git a/messages/de-DE.json b/messages/de-DE.json
index 8a5e9b68..15663fa4 100644
--- a/messages/de-DE.json
+++ b/messages/de-DE.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rolle entfernt",
"accessRoleRemovedDescription": "Die Rolle wurde erfolgreich entfernt.",
"accessRoleRequiredRemove": "Bevor Sie diese Rolle löschen, wählen Sie bitte eine neue Rolle aus, zu der die bestehenden Mitglieder übertragen werden sollen.",
+ "network": "Netzwerk",
"manage": "Verwalten",
"sitesNotFound": "Keine Standorte gefunden.",
"pangolinServerAdmin": "Server-Admin - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privat",
"sidebarAccessControl": "Zugriffskontrolle",
"sidebarLogsAndAnalytics": "Protokolle & Analysen",
+ "sidebarTeam": "Team",
"sidebarUsers": "Benutzer",
"sidebarAdmin": "Admin",
"sidebarInvitations": "Einladungen",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytik",
"sidebarBluePrints": "Blaupausen",
"sidebarOrganization": "Organisation",
+ "sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Abrechnung & Lizenzen",
"sidebarLogsAnalytics": "Analytik",
"blueprints": "Blaupausen",
@@ -1289,7 +1292,6 @@
"parsedContents": "Analysierte Inhalte (Nur lesen)",
"enableDockerSocket": "Docker Blueprint aktivieren",
"enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blueprintbeschriftungen. Der Socket-Pfad muss neu angegeben werden.",
- "enableDockerSocketLink": "Mehr erfahren",
"viewDockerContainers": "Docker Container anzeigen",
"containersIn": "Container in {siteName}",
"selectContainerDescription": "Wählen Sie einen Container, der als Hostname für dieses Ziel verwendet werden soll. Klicken Sie auf einen Port, um einen Port zu verwenden.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Verfügbarkeitshinweis",
"billingFeatureLossDescription": "Durch Herabstufung werden Funktionen, die im neuen Paket nicht verfügbar sind, automatisch deaktiviert. Einige Einstellungen und Konfigurationen können verloren gehen. Bitte überprüfen Sie die Preismatrix um zu verstehen, welche Funktionen nicht mehr verfügbar sein werden.",
"billingUsageExceedsLimit": "Aktuelle Nutzung ({current}) überschreitet das Limit ({limit})",
+ "billingPastDueTitle": "Zahlung vergangene Fälligkeit",
+ "billingPastDueDescription": "Ihre Zahlung ist abgelaufen. Bitte aktualisieren Sie Ihre Zahlungsmethode, um die aktuellen Funktionen Ihres Pakets weiter zu nutzen. Wenn nicht geklärt, wird Ihr Abonnement abgebrochen und Sie werden auf die kostenlose Stufe zurückgekehrt.",
+ "billingUnpaidTitle": "Unbezahltes Abonnement",
+ "billingUnpaidDescription": "Dein Abonnement ist unbezahlt und du wurdest auf die kostenlose Stufe zurückgekehrt. Bitte aktualisiere deine Zahlungsmethode, um dein Abonnement wiederherzustellen.",
+ "billingIncompleteTitle": "Zahlung unvollständig",
+ "billingIncompleteDescription": "Ihre Zahlung ist unvollständig. Bitte schließen Sie den Zahlungsvorgang ab, um Ihr Abonnement zu aktivieren.",
+ "billingIncompleteExpiredTitle": "Zahlung abgelaufen",
+ "billingIncompleteExpiredDescription": "Deine Zahlung wurde nie abgeschlossen und ist abgelaufen. Du wurdest zur kostenlosen Stufe zurückgekehrt. Bitte melde dich erneut an, um den Zugriff auf kostenpflichtige Funktionen wiederherzustellen.",
+ "billingManageSubscription": "Verwalten Sie Ihr Abonnement",
+ "billingResolvePaymentIssue": "Bitte beheben Sie Ihr Zahlungsproblem vor dem Upgrade oder Herabstufen",
"signUpTerms": {
"IAgreeToThe": "Ich stimme den",
"termsOfService": "Nutzungsbedingungen zu",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Zeit ist in Sekunden",
"requireDeviceApproval": "Gerätegenehmigungen erforderlich",
"requireDeviceApprovalDescription": "Benutzer mit dieser Rolle benötigen neue Geräte, die von einem Administrator genehmigt wurden, bevor sie sich verbinden und auf Ressourcen zugreifen können.",
+ "sshAccess": "SSH-Zugriff",
+ "roleAllowSsh": "SSH erlauben",
+ "roleAllowSshAllow": "Erlauben",
+ "roleAllowSshDisallow": "Nicht zulassen",
+ "roleAllowSshDescription": "Benutzern mit dieser Rolle erlauben, sich über SSH mit Ressourcen zu verbinden. Wenn deaktiviert, kann die Rolle keinen SSH-Zugriff verwenden.",
+ "sshSudoMode": "Sudo-Zugriff",
+ "sshSudoModeNone": "Keine",
+ "sshSudoModeNoneDescription": "Benutzer kann keine Befehle mit sudo ausführen.",
+ "sshSudoModeFull": "Volles Sudo",
+ "sshSudoModeFullDescription": "Benutzer kann jeden Befehl mit sudo ausführen.",
+ "sshSudoModeCommands": "Befehle",
+ "sshSudoModeCommandsDescription": "Benutzer kann nur die angegebenen Befehle mit sudo ausführen.",
+ "sshSudo": "sudo erlauben",
+ "sshSudoCommands": "Sudo-Befehle",
+ "sshSudoCommandsDescription": "Liste der Befehle, die der Benutzer mit sudo ausführen darf.",
+ "sshCreateHomeDir": "Home-Verzeichnis erstellen",
+ "sshUnixGroups": "Unix-Gruppen",
+ "sshUnixGroupsDescription": "Unix-Gruppen, zu denen der Benutzer auf dem Ziel-Host hinzugefügt wird.",
"retryAttempts": "Wiederholungsversuche",
"expectedResponseCodes": "Erwartete Antwortcodes",
"expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Zugriffskontrolle",
"editInternalResourceDialogAccessControlDescription": "Kontrollieren Sie, welche Rollen, Benutzer und Maschinen-Clients Zugriff auf diese Ressource haben, wenn sie verbunden sind. Admins haben immer Zugriff.",
"editInternalResourceDialogPortRangeValidationError": "Der Port-Bereich muss \"*\" für alle Ports sein, oder eine kommaseparierte Liste von Ports und Bereichen (z.B. \"80,443.8000-9000\"). Ports müssen zwischen 1 und 65535 liegen.",
+ "internalResourceAuthDaemonStrategy": "SSH Auth-Daemon Standort",
+ "internalResourceAuthDaemonStrategyDescription": "Wählen Sie aus, wo der SSH-Authentifizierungs-Daemon läuft: auf der Site (Newt) oder auf einem entfernten Host.",
+ "internalResourceAuthDaemonDescription": "Der SSH-Authentifizierungs-Daemon verarbeitet SSH-Schlüsselsignaturen und PAM-Authentifizierung für diese Ressource. Wählen Sie, ob sie auf der Website (Newt) oder auf einem separaten entfernten Host ausgeführt wird. Siehe die Dokumentation für mehr.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Strategie auswählen",
+ "internalResourceAuthDaemonStrategyLabel": "Standort",
+ "internalResourceAuthDaemonSite": "Vor Ort",
+ "internalResourceAuthDaemonSiteDescription": "Der Auth Daemon läuft auf der Seite (Newt).",
+ "internalResourceAuthDaemonRemote": "Entfernter Host",
+ "internalResourceAuthDaemonRemoteDescription": "Der Auth Daemon läuft auf einem Host, der nicht die Site ist.",
+ "internalResourceAuthDaemonPort": "Daemon-Port (optional)",
"orgAuthWhatsThis": "Wo finde ich meine Organisations-ID?",
"learnMore": "Mehr erfahren",
"backToHome": "Zurück zur Startseite",
diff --git a/messages/en-US.json b/messages/en-US.json
index 44d980c5..d872d8e3 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "User and role-based access control",
"resourcesErrorUpdate": "Failed to toggle resource",
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
- "access": "Access",
+ "access": "Access Control",
"shareLink": "{resource} Share Link",
"resourceSelect": "Select resource",
"shareLinks": "Share Links",
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Role removed",
"accessRoleRemovedDescription": "The role has been successfully removed.",
"accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.",
+ "network": "Network",
"manage": "Manage",
"sitesNotFound": "No sites found.",
"pangolinServerAdmin": "Server Admin - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Private",
"sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics",
+ "sidebarTeam": "Team",
"sidebarUsers": "Users",
"sidebarAdmin": "Admin",
"sidebarInvitations": "Invitations",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints",
"sidebarOrganization": "Organization",
+ "sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics",
"blueprints": "Blueprints",
@@ -1288,8 +1291,7 @@
"contents": "Contents",
"parsedContents": "Parsed Contents (Read Only)",
"enableDockerSocket": "Enable Docker Blueprint",
- "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
- "enableDockerSocketLink": "Learn More",
+ "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. Read about how this works in the documentation.",
"viewDockerContainers": "View Docker Containers",
"containersIn": "Containers in {siteName}",
"selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Feature Availability Notice",
"billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.",
"billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})",
+ "billingPastDueTitle": "Payment Past Due",
+ "billingPastDueDescription": "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier.",
+ "billingUnpaidTitle": "Subscription Unpaid",
+ "billingUnpaidDescription": "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription.",
+ "billingIncompleteTitle": "Payment Incomplete",
+ "billingIncompleteDescription": "Your payment is incomplete. Please complete the payment process to activate your subscription.",
+ "billingIncompleteExpiredTitle": "Payment Expired",
+ "billingIncompleteExpiredDescription": "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features.",
+ "billingManageSubscription": "Manage your subscription",
+ "billingResolvePaymentIssue": "Please resolve your payment issue before upgrading or downgrading",
"signUpTerms": {
"IAgreeToThe": "I agree to the",
"termsOfService": "terms of service",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
+ "sshAccess": "SSH Access",
+ "roleAllowSsh": "Allow SSH",
+ "roleAllowSshAllow": "Allow",
+ "roleAllowSshDisallow": "Disallow",
+ "roleAllowSshDescription": "Allow users with this role to connect to resources via SSH. When disabled, the role cannot use SSH access.",
+ "sshSudoMode": "Sudo Access",
+ "sshSudoModeNone": "None",
+ "sshSudoModeNoneDescription": "User cannot run commands with sudo.",
+ "sshSudoModeFull": "Full Sudo",
+ "sshSudoModeFullDescription": "User can run any command with sudo.",
+ "sshSudoModeCommands": "Commands",
+ "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
+ "sshSudo": "Allow sudo",
+ "sshSudoCommands": "Sudo Commands",
+ "sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo.",
+ "sshCreateHomeDir": "Create Home Directory",
+ "sshUnixGroups": "Unix Groups",
+ "sshUnixGroupsDescription": "Unix groups to add the user to on the target host.",
"retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
@@ -1988,8 +2018,8 @@
"orgAuthNoAccount": "Don't have an account?",
"subscriptionRequiredToUse": "A subscription is required to use this feature.",
"mustUpgradeToUse": "You must upgrade your subscription to use this feature.",
- "subscriptionRequiredTierToUse": "This feature requires {tier} or higher.",
- "upgradeToTierToUse": "Upgrade to {tier} or higher to use this feature.",
+ "subscriptionRequiredTierToUse": "This feature requires {tier}.",
+ "upgradeToTierToUse": "Upgrade to {tier} to use this feature.",
"subscriptionTierTier1": "Home",
"subscriptionTierTier2": "Team",
"subscriptionTierTier3": "Business",
@@ -2079,7 +2109,7 @@
"manageMachineClients": "Manage Machine Clients",
"manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources",
"machineClientsBannerTitle": "Servers & Automated Systems",
- "machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
+ "machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can be deployed as a CLI or a container.",
"machineClientsBannerPangolinCLI": "Pangolin CLI",
"machineClientsBannerOlmCLI": "Olm CLI",
"machineClientsBannerOlmContainer": "Container",
@@ -2305,7 +2335,7 @@
"logRetentionEndOfFollowingYear": "End of following year",
"actionLogsDescription": "View a history of actions performed in this organization",
"accessLogsDescription": "View access auth requests for resources in this organization",
- "licenseRequiredToUse": "An Enterprise Edition license is required to use this feature. This feature is also available in Pangolin Cloud.",
+ "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature.",
"ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud.",
"certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Access Control",
"editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.",
"editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535.",
+ "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Location",
+ "internalResourceAuthDaemonStrategyDescription": "Choose where the SSH authentication daemon runs: on the site (Newt) or on a remote host.",
+ "internalResourceAuthDaemonDescription": "The SSH authentication daemon handles SSH key signing and PAM authentication for this resource. Choose whether it runs on the site (Newt) or on a separate remote host. See the documentation for more.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Select Strategy",
+ "internalResourceAuthDaemonStrategyLabel": "Location",
+ "internalResourceAuthDaemonSite": "On Site",
+ "internalResourceAuthDaemonSiteDescription": "Auth daemon runs on the site (Newt).",
+ "internalResourceAuthDaemonRemote": "Remote Host",
+ "internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on a host that is not the site.",
+ "internalResourceAuthDaemonPort": "Daemon Port (optional)",
"orgAuthWhatsThis": "Where can I find my organization ID?",
"learnMore": "Learn more",
"backToHome": "Go back to home",
diff --git a/messages/es-ES.json b/messages/es-ES.json
index d520aaf4..6bb73cda 100644
--- a/messages/es-ES.json
+++ b/messages/es-ES.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rol eliminado",
"accessRoleRemovedDescription": "El rol se ha eliminado correctamente.",
"accessRoleRequiredRemove": "Antes de eliminar este rol, seleccione un nuevo rol al que transferir miembros existentes.",
+ "network": "Red",
"manage": "Gestionar",
"sitesNotFound": "Sitios no encontrados.",
"pangolinServerAdmin": "Admin Servidor - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privado",
"sidebarAccessControl": "Control de acceso",
"sidebarLogsAndAnalytics": "Registros y análisis",
+ "sidebarTeam": "Equipo",
"sidebarUsers": "Usuarios",
"sidebarAdmin": "Admin",
"sidebarInvitations": "Invitaciones",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Registro y análisis",
"sidebarBluePrints": "Planos",
"sidebarOrganization": "Organización",
+ "sidebarManagement": "Gestión",
"sidebarBillingAndLicenses": "Facturación y licencias",
"sidebarLogsAnalytics": "Analíticas",
"blueprints": "Planos",
@@ -1289,7 +1292,6 @@
"parsedContents": "Contenido analizado (Sólo lectura)",
"enableDockerSocket": "Habilitar Plano Docker",
"enableDockerSocketDescription": "Activar el raspado de etiquetas de Socket Docker para etiquetas de planos. La ruta del Socket debe proporcionarse a Newt.",
- "enableDockerSocketLink": "Saber más",
"viewDockerContainers": "Ver contenedores Docker",
"containersIn": "Contenedores en {siteName}",
"selectContainerDescription": "Seleccione cualquier contenedor para usar como nombre de host para este objetivo. Haga clic en un puerto para usar un puerto.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Aviso de disponibilidad de funcionalidad",
"billingFeatureLossDescription": "Al degradar, las características no disponibles en el nuevo plan se desactivarán automáticamente. Algunas configuraciones y configuraciones pueden perderse. Por favor, revise la matriz de precios para entender qué características ya no estarán disponibles.",
"billingUsageExceedsLimit": "El uso actual ({current}) supera el límite ({limit})",
+ "billingPastDueTitle": "Pago vencido",
+ "billingPastDueDescription": "Su pago ha vencido. Por favor, actualice su método de pago para seguir utilizando las características actuales de su plan. Si no se resuelve, tu suscripción se cancelará y serás revertido al nivel gratuito.",
+ "billingUnpaidTitle": "Suscripción no pagada",
+ "billingUnpaidDescription": "Tu suscripción no está pagada y has sido revertido al nivel gratuito. Por favor, actualiza tu método de pago para restaurar tu suscripción.",
+ "billingIncompleteTitle": "Pago incompleto",
+ "billingIncompleteDescription": "Su pago está incompleto. Por favor, complete el proceso de pago para activar su suscripción.",
+ "billingIncompleteExpiredTitle": "Pago expirado",
+ "billingIncompleteExpiredDescription": "Tu pago nunca se completó y ha expirado. Has sido revertido al nivel gratuito. Suscríbete de nuevo para restaurar el acceso a las funciones de pago.",
+ "billingManageSubscription": "Administra tu suscripción",
+ "billingResolvePaymentIssue": "Por favor resuelva su problema de pago antes de actualizar o bajar de calificación",
"signUpTerms": {
"IAgreeToThe": "Estoy de acuerdo con los",
"termsOfService": "términos del servicio",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "El tiempo está en segundos",
"requireDeviceApproval": "Requiere aprobaciones del dispositivo",
"requireDeviceApprovalDescription": "Los usuarios con este rol necesitan nuevos dispositivos aprobados por un administrador antes de poder conectarse y acceder a los recursos.",
+ "sshAccess": "Acceso a SSH",
+ "roleAllowSsh": "Permitir SSH",
+ "roleAllowSshAllow": "Permitir",
+ "roleAllowSshDisallow": "Rechazar",
+ "roleAllowSshDescription": "Permitir a los usuarios con este rol conectarse a recursos a través de SSH. Cuando está desactivado, el rol no puede usar acceso SSH.",
+ "sshSudoMode": "Acceso Sudo",
+ "sshSudoModeNone": "Ninguna",
+ "sshSudoModeNoneDescription": "El usuario no puede ejecutar comandos con sudo.",
+ "sshSudoModeFull": "Sudo completo",
+ "sshSudoModeFullDescription": "El usuario puede ejecutar cualquier comando con sudo.",
+ "sshSudoModeCommands": "Comandos",
+ "sshSudoModeCommandsDescription": "El usuario sólo puede ejecutar los comandos especificados con sudo.",
+ "sshSudo": "Permitir sudo",
+ "sshSudoCommands": "Comandos Sudo",
+ "sshSudoCommandsDescription": "Lista de comandos que el usuario puede ejecutar con sudo.",
+ "sshCreateHomeDir": "Crear directorio principal",
+ "sshUnixGroups": "Grupos Unix",
+ "sshUnixGroupsDescription": "Grupos Unix para agregar el usuario en el host de destino.",
"retryAttempts": "Intentos de Reintento",
"expectedResponseCodes": "Códigos de respuesta esperados",
"expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Control de acceso",
"editInternalResourceDialogAccessControlDescription": "Controla qué roles, usuarios y clientes de máquinas tienen acceso a este recurso cuando están conectados. Los administradores siempre tienen acceso.",
"editInternalResourceDialogPortRangeValidationError": "El rango de puertos debe ser \"*\" para todos los puertos, o una lista separada por comas de puertos y rangos (por ejemplo, \"80,443,8000-9000\"). Los puertos deben estar entre 1 y 65535.",
+ "internalResourceAuthDaemonStrategy": "Ubicación del demonio de autenticación SSSH",
+ "internalResourceAuthDaemonStrategyDescription": "Elija dónde se ejecuta el daemon de autenticación SSH: en el sitio (Newt) o en un host remoto.",
+ "internalResourceAuthDaemonDescription": "El daemon de autenticación SSSH maneja la firma de claves SSH y autenticación PAM para este recurso. Elija si se ejecuta en el sitio (Newt) o en un host remoto separado. Vea la documentación para más.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Seleccionar estrategia",
+ "internalResourceAuthDaemonStrategyLabel": "Ubicación",
+ "internalResourceAuthDaemonSite": "En el sitio",
+ "internalResourceAuthDaemonSiteDescription": "Auth daemon corre en el sitio (Newt).",
+ "internalResourceAuthDaemonRemote": "Host remoto",
+ "internalResourceAuthDaemonRemoteDescription": "El daemon Auth corre en un host que no es el sitio.",
+ "internalResourceAuthDaemonPort": "Puerto de demonio (opcional)",
"orgAuthWhatsThis": "¿Dónde puedo encontrar el ID de mi organización?",
"learnMore": "Más información",
"backToHome": "Volver a inicio",
diff --git a/messages/fr-FR.json b/messages/fr-FR.json
index 23d988b7..004354f1 100644
--- a/messages/fr-FR.json
+++ b/messages/fr-FR.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rôle supprimé",
"accessRoleRemovedDescription": "Le rôle a été supprimé avec succès.",
"accessRoleRequiredRemove": "Avant de supprimer ce rôle, veuillez sélectionner un nouveau rôle pour transférer les membres existants.",
+ "network": "Réseau",
"manage": "Gérer",
"sitesNotFound": "Aucun site trouvé.",
"pangolinServerAdmin": "Admin Serveur - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privé",
"sidebarAccessControl": "Contrôle d'accès",
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
+ "sidebarTeam": "Equipe",
"sidebarUsers": "Utilisateurs",
"sidebarAdmin": "Administrateur",
"sidebarInvitations": "Invitations",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Journaux & Analytiques",
"sidebarBluePrints": "Configs",
"sidebarOrganization": "Organisation",
+ "sidebarManagement": "Gestion",
"sidebarBillingAndLicenses": "Facturation & Licences",
"sidebarLogsAnalytics": "Analyses",
"blueprints": "Configs",
@@ -1289,7 +1292,6 @@
"parsedContents": "Contenu analysé (lecture seule)",
"enableDockerSocket": "Activer la Config Docker",
"enableDockerSocketDescription": "Activer le ramassage d'étiquettes de socket Docker pour les étiquettes de plan. Le chemin de socket doit être fourni à Newt.",
- "enableDockerSocketLink": "En savoir plus",
"viewDockerContainers": "Voir les conteneurs Docker",
"containersIn": "Conteneurs en {siteName}",
"selectContainerDescription": "Sélectionnez n'importe quel conteneur à utiliser comme nom d'hôte pour cette cible. Cliquez sur un port pour utiliser un port.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Avis de disponibilité des fonctionnalités",
"billingFeatureLossDescription": "En rétrogradant, les fonctionnalités non disponibles dans le nouveau plan seront automatiquement désactivées. Certains paramètres et configurations peuvent être perdus. Veuillez consulter la matrice de prix pour comprendre quelles fonctionnalités ne seront plus disponibles.",
"billingUsageExceedsLimit": "L'utilisation actuelle ({current}) dépasse la limite ({limit})",
+ "billingPastDueTitle": "Paiement en retard",
+ "billingPastDueDescription": "Votre paiement est échu. Veuillez mettre à jour votre méthode de paiement pour continuer à utiliser les fonctionnalités de votre plan actuel. Si non résolu, votre abonnement sera annulé et vous serez remis au niveau gratuit.",
+ "billingUnpaidTitle": "Abonnement impayé",
+ "billingUnpaidDescription": "Votre abonnement est impayé et vous avez été reversé au niveau gratuit. Veuillez mettre à jour votre méthode de paiement pour restaurer votre abonnement.",
+ "billingIncompleteTitle": "Paiement incomplet",
+ "billingIncompleteDescription": "Votre paiement est incomplet. Veuillez compléter le processus de paiement pour activer votre abonnement.",
+ "billingIncompleteExpiredTitle": "Paiement expiré",
+ "billingIncompleteExpiredDescription": "Votre paiement n'a jamais été complété et a expiré. Vous avez été restauré au niveau gratuit. Veuillez vous abonner à nouveau pour restaurer l'accès aux fonctionnalités payantes.",
+ "billingManageSubscription": "Gérer votre abonnement",
+ "billingResolvePaymentIssue": "Veuillez résoudre votre problème de paiement avant de procéder à la mise à niveau ou à la rétrogradation",
"signUpTerms": {
"IAgreeToThe": "Je suis d'accord avec",
"termsOfService": "les conditions d'utilisation",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Le temps est exprimé en secondes",
"requireDeviceApproval": "Exiger les autorisations de l'appareil",
"requireDeviceApprovalDescription": "Les utilisateurs ayant ce rôle ont besoin de nouveaux périphériques approuvés par un administrateur avant de pouvoir se connecter et accéder aux ressources.",
+ "sshAccess": "Accès SSH",
+ "roleAllowSsh": "Autoriser SSH",
+ "roleAllowSshAllow": "Autoriser",
+ "roleAllowSshDisallow": "Interdire",
+ "roleAllowSshDescription": "Autoriser les utilisateurs avec ce rôle à se connecter aux ressources via SSH. Lorsque désactivé, le rôle ne peut pas utiliser les accès SSH.",
+ "sshSudoMode": "Accès Sudo",
+ "sshSudoModeNone": "Aucun",
+ "sshSudoModeNoneDescription": "L'utilisateur ne peut pas exécuter de commandes avec sudo.",
+ "sshSudoModeFull": "Sudo complet",
+ "sshSudoModeFullDescription": "L'utilisateur peut exécuter n'importe quelle commande avec sudo.",
+ "sshSudoModeCommands": "Commandes",
+ "sshSudoModeCommandsDescription": "L'utilisateur ne peut exécuter que les commandes spécifiées avec sudo.",
+ "sshSudo": "Autoriser sudo",
+ "sshSudoCommands": "Commandes Sudo",
+ "sshSudoCommandsDescription": "Liste des commandes que l'utilisateur est autorisé à exécuter avec sudo.",
+ "sshCreateHomeDir": "Créer un répertoire personnel",
+ "sshUnixGroups": "Groupes Unix",
+ "sshUnixGroupsDescription": "Groupes Unix à ajouter à l'utilisateur sur l'hôte cible.",
"retryAttempts": "Tentatives de réessai",
"expectedResponseCodes": "Codes de réponse attendus",
"expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Contrôle d'accès",
"editInternalResourceDialogAccessControlDescription": "Contrôlez quels rôles, utilisateurs et clients de machine ont accès à cette ressource lorsqu'ils sont connectés. Les administrateurs ont toujours accès.",
"editInternalResourceDialogPortRangeValidationError": "La plage de ports doit être \"*\" pour tous les ports, ou une liste de ports et de plages séparés par des virgules (par exemple, \"80,443,8000-9000\"). Les ports doivent être compris entre 1 et 65535.",
+ "internalResourceAuthDaemonStrategy": "Emplacement du démon d'authentification SSH",
+ "internalResourceAuthDaemonStrategyDescription": "Choisissez où le démon d'authentification SSH s'exécute : sur le site (Newt) ou sur un hôte distant.",
+ "internalResourceAuthDaemonDescription": "Le démon d'authentification SSH gère la signature des clés SSH et l'authentification PAM pour cette ressource. Choisissez s'il fonctionne sur le site (Newt) ou sur un hôte distant séparé. Voir la documentation pour plus d'informations.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Choisir une stratégie",
+ "internalResourceAuthDaemonStrategyLabel": "Localisation",
+ "internalResourceAuthDaemonSite": "Sur le site",
+ "internalResourceAuthDaemonSiteDescription": "Le démon Auth fonctionne sur le site (Newt).",
+ "internalResourceAuthDaemonRemote": "Hôte distant",
+ "internalResourceAuthDaemonRemoteDescription": "Le démon Auth fonctionne sur un hôte qui n'est pas le site.",
+ "internalResourceAuthDaemonPort": "Port du démon (optionnel)",
"orgAuthWhatsThis": "Où puis-je trouver mon identifiant d'organisation ?",
"learnMore": "En savoir plus",
"backToHome": "Retour à l'accueil",
diff --git a/messages/it-IT.json b/messages/it-IT.json
index 77ff1c2b..5a60a296 100644
--- a/messages/it-IT.json
+++ b/messages/it-IT.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Ruolo rimosso",
"accessRoleRemovedDescription": "Il ruolo è stato rimosso con successo.",
"accessRoleRequiredRemove": "Prima di eliminare questo ruolo, seleziona un nuovo ruolo a cui trasferire i membri esistenti.",
+ "network": "Rete",
"manage": "Gestisci",
"sitesNotFound": "Nessun sito trovato.",
"pangolinServerAdmin": "Server Admin - Pangolina",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privato",
"sidebarAccessControl": "Controllo Accesso",
"sidebarLogsAndAnalytics": "Registri E Analisi",
+ "sidebarTeam": "Squadra",
"sidebarUsers": "Utenti",
"sidebarAdmin": "Amministratore",
"sidebarInvitations": "Inviti",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Progetti",
"sidebarOrganization": "Organizzazione",
+ "sidebarManagement": "Gestione",
"sidebarBillingAndLicenses": "Fatturazione E Licenze",
"sidebarLogsAnalytics": "Analisi",
"blueprints": "Progetti",
@@ -1289,7 +1292,6 @@
"parsedContents": "Sommario Analizzato (Solo Lettura)",
"enableDockerSocket": "Abilita Progetto Docker",
"enableDockerSocketDescription": "Abilita la raschiatura dell'etichetta Docker Socket per le etichette dei progetti. Il percorso del socket deve essere fornito a Newt.",
- "enableDockerSocketLink": "Scopri di più",
"viewDockerContainers": "Visualizza Contenitori Docker",
"containersIn": "Contenitori in {siteName}",
"selectContainerDescription": "Seleziona qualsiasi contenitore da usare come hostname per questo obiettivo. Fai clic su una porta per usare una porta.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Avviso Di Disponibilità Caratteristica",
"billingFeatureLossDescription": "Con il downgrading, le funzioni non disponibili nel nuovo piano saranno disattivate automaticamente. Alcune impostazioni e configurazioni potrebbero andare perse. Controlla la matrice dei prezzi per capire quali funzioni non saranno più disponibili.",
"billingUsageExceedsLimit": "L'utilizzo corrente ({current}) supera il limite ({limit})",
+ "billingPastDueTitle": "Pagamento Scaduto",
+ "billingPastDueDescription": "Il pagamento è scaduto. Si prega di aggiornare il metodo di pagamento per continuare a utilizzare le funzioni del piano corrente. Se non risolto, il tuo abbonamento verrà annullato e verrai ripristinato al livello gratuito.",
+ "billingUnpaidTitle": "Abbonamento Non Pagato",
+ "billingUnpaidDescription": "Il tuo abbonamento non è pagato e sei stato restituito al livello gratuito. Per favore aggiorna il metodo di pagamento per ripristinare l'abbonamento.",
+ "billingIncompleteTitle": "Pagamento Incompleto",
+ "billingIncompleteDescription": "Il pagamento è incompleto. Si prega di completare il processo di pagamento per attivare il tuo abbonamento.",
+ "billingIncompleteExpiredTitle": "Pagamento Scaduto",
+ "billingIncompleteExpiredDescription": "Il tuo pagamento non è mai stato completato ed è scaduto. Sei stato ripristinato al livello gratuito. Si prega di iscriversi nuovamente per ripristinare l'accesso alle funzionalità a pagamento.",
+ "billingManageSubscription": "Gestisci il tuo abbonamento",
+ "billingResolvePaymentIssue": "Si prega di risolvere il problema di pagamento prima di aggiornare o declassare",
"signUpTerms": {
"IAgreeToThe": "Accetto i",
"termsOfService": "termini di servizio",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Il tempo è in secondi",
"requireDeviceApproval": "Richiede Approvazioni Dispositivo",
"requireDeviceApprovalDescription": "Gli utenti con questo ruolo hanno bisogno di nuovi dispositivi approvati da un amministratore prima di poter connettersi e accedere alle risorse.",
+ "sshAccess": "Accesso SSH",
+ "roleAllowSsh": "Consenti SSH",
+ "roleAllowSshAllow": "Consenti",
+ "roleAllowSshDisallow": "Non Consentire",
+ "roleAllowSshDescription": "Consenti agli utenti con questo ruolo di connettersi alle risorse tramite SSH. Quando disabilitato, il ruolo non può utilizzare l'accesso SSH.",
+ "sshSudoMode": "Accesso Sudo",
+ "sshSudoModeNone": "Nessuno",
+ "sshSudoModeNoneDescription": "L'utente non può eseguire comandi con sudo.",
+ "sshSudoModeFull": "Sudo Completo",
+ "sshSudoModeFullDescription": "L'utente può eseguire qualsiasi comando con sudo.",
+ "sshSudoModeCommands": "Comandi",
+ "sshSudoModeCommandsDescription": "L'utente può eseguire solo i comandi specificati con sudo.",
+ "sshSudo": "Consenti sudo",
+ "sshSudoCommands": "Comandi Sudo",
+ "sshSudoCommandsDescription": "Elenco di comandi che l'utente può eseguire con sudo.",
+ "sshCreateHomeDir": "Crea Cartella Home",
+ "sshUnixGroups": "Gruppi Unix",
+ "sshUnixGroupsDescription": "Gruppi Unix su cui aggiungere l'utente sull'host di destinazione.",
"retryAttempts": "Tentativi di Riprova",
"expectedResponseCodes": "Codici di Risposta Attesi",
"expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Controllo Accesso",
"editInternalResourceDialogAccessControlDescription": "Controlla quali ruoli, utenti e client macchina hanno accesso a questa risorsa quando connessi. Gli amministratori hanno sempre accesso.",
"editInternalResourceDialogPortRangeValidationError": "Il range delle porte deve essere \"*\" per tutte le porte, o un elenco di porte e intervalli separato da virgole (ad es. \"80,443,8000-9000\"). Le porte devono essere tra 1 e 65535.",
+ "internalResourceAuthDaemonStrategy": "Posizione Demone Autenticazione SSH",
+ "internalResourceAuthDaemonStrategyDescription": "Scegli dove funziona il demone di autenticazione SSH: sul sito (Newt) o su un host remoto.",
+ "internalResourceAuthDaemonDescription": "Il demone di autenticazione SSH gestisce la firma della chiave SSH e l'autenticazione PAM per questa risorsa. Scegli se viene eseguito sul sito (Newt) o su un host remoto separato. Vedi la documentazione per ulteriori informazioni.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Seleziona Strategia",
+ "internalResourceAuthDaemonStrategyLabel": "Posizione",
+ "internalResourceAuthDaemonSite": "Sul Sito",
+ "internalResourceAuthDaemonSiteDescription": "Il demone Auth viene eseguito sul sito (Nuovo).",
+ "internalResourceAuthDaemonRemote": "Host Remoto",
+ "internalResourceAuthDaemonRemoteDescription": "Il demone di autenticazione viene eseguito su un host che non è il sito.",
+ "internalResourceAuthDaemonPort": "Porta Demone (facoltativa)",
"orgAuthWhatsThis": "Dove posso trovare l'ID della mia organizzazione?",
"learnMore": "Scopri di più",
"backToHome": "Torna alla home",
diff --git a/messages/ko-KR.json b/messages/ko-KR.json
index 7d53b8b9..03865810 100644
--- a/messages/ko-KR.json
+++ b/messages/ko-KR.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "역할이 제거되었습니다",
"accessRoleRemovedDescription": "역할이 성공적으로 제거되었습니다.",
"accessRoleRequiredRemove": "이 역할을 삭제하기 전에 기존 구성원을 전송할 새 역할을 선택하세요.",
+ "network": "네트워크",
"manage": "관리",
"sitesNotFound": "사이트를 찾을 수 없습니다.",
"pangolinServerAdmin": "서버 관리자 - 판골린",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "비공개",
"sidebarAccessControl": "액세스 제어",
"sidebarLogsAndAnalytics": "로그 및 분석",
+ "sidebarTeam": "팀",
"sidebarUsers": "사용자",
"sidebarAdmin": "관리자",
"sidebarInvitations": "초대",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "로그 & 통계",
"sidebarBluePrints": "청사진",
"sidebarOrganization": "조직",
+ "sidebarManagement": "관리",
"sidebarBillingAndLicenses": "결제 및 라이선스",
"sidebarLogsAnalytics": "분석",
"blueprints": "청사진",
@@ -1289,7 +1292,6 @@
"parsedContents": "구문 분석된 콘텐츠 (읽기 전용)",
"enableDockerSocket": "Docker 청사진 활성화",
"enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 수집을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
- "enableDockerSocketLink": "자세히 알아보기",
"viewDockerContainers": "도커 컨테이너 보기",
"containersIn": "{siteName}의 컨테이너",
"selectContainerDescription": "이 대상을 위한 호스트 이름으로 사용할 컨테이너를 선택하세요. 포트를 사용하려면 포트를 클릭하세요.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "기능 가용성 알림",
"billingFeatureLossDescription": "다운그레이드함으로써 새 계획에서 사용할 수 없는 기능은 자동으로 비활성화됩니다. 일부 설정 및 구성은 손실될 수 있습니다. 어떤 기능들이 더 이상 사용 불가능한지 이해하기 위해 가격표를 검토하세요.",
"billingUsageExceedsLimit": "현재 사용량 ({current})이 제한 ({limit})을 초과합니다",
+ "billingPastDueTitle": "연체된 결제",
+ "billingPastDueDescription": "결제가 연체되었습니다. 현재 이용 중인 플랜 기능을 계속 사용하기 위해 결제 수단을 업데이트해 주세요. 해결되지 않으면 구독이 취소되고 무료 요금제로 전환됩니다.",
+ "billingUnpaidTitle": "결제되지 않은 구독",
+ "billingUnpaidDescription": "구독 결제가 완료되지 않아 무료 요금제로 전환되었습니다. 구독을 복원하려면 결제 수단을 업데이트해 주세요.",
+ "billingIncompleteTitle": "불완전한 결제",
+ "billingIncompleteDescription": "결제가 불완전합니다. 구독을 활성화하기 위해 결제 과정을 완료해 주세요.",
+ "billingIncompleteExpiredTitle": "만료된 결제",
+ "billingIncompleteExpiredDescription": "결제가 완료되지 않아 만료되었습니다. 무료 요금제로 전환되었습니다. 유료 기능에 대한 액세스를 복원하려면 다시 구독해 주세요.",
+ "billingManageSubscription": "구독을 관리하십시오",
+ "billingResolvePaymentIssue": "업그레이드 또는 다운그레이드하기 전에 결제 문제를 해결해 주세요.",
"signUpTerms": {
"IAgreeToThe": "동의합니다",
"termsOfService": "서비스 약관",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "시간은 초 단위입니다",
"requireDeviceApproval": "장치 승인 요구",
"requireDeviceApprovalDescription": "이 역할을 가진 사용자는 장치가 연결되기 전에 관리자의 승인이 필요합니다.",
+ "sshAccess": "SSH 접속",
+ "roleAllowSsh": "SSH 허용",
+ "roleAllowSshAllow": "허용",
+ "roleAllowSshDisallow": "허용 안 함",
+ "roleAllowSshDescription": "이 역할을 가진 사용자가 SSH를 통해 리소스에 연결할 수 있도록 허용합니다. 비활성화되면 역할은 SSH 접속을 사용할 수 없습니다.",
+ "sshSudoMode": "Sudo 접속",
+ "sshSudoModeNone": "없음",
+ "sshSudoModeNoneDescription": "사용자는 sudo로 명령을 실행할 수 없습니다.",
+ "sshSudoModeFull": "전체 Sudo",
+ "sshSudoModeFullDescription": "사용자는 모든 명령을 sudo로 실행할 수 있습니다.",
+ "sshSudoModeCommands": "명령",
+ "sshSudoModeCommandsDescription": "사용자는 sudo로 지정된 명령만 실행할 수 있습니다.",
+ "sshSudo": "Sudo 허용",
+ "sshSudoCommands": "Sudo 명령",
+ "sshSudoCommandsDescription": "사용자가 sudo로 실행할 수 있도록 허용된 명령 목록입니다.",
+ "sshCreateHomeDir": "홈 디렉터리 생성",
+ "sshUnixGroups": "유닉스 그룹",
+ "sshUnixGroupsDescription": "대상 호스트에서 사용자를 추가할 유닉스 그룹입니다.",
"retryAttempts": "재시도 횟수",
"expectedResponseCodes": "예상 응답 코드",
"expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "액세스 제어",
"editInternalResourceDialogAccessControlDescription": "연결 시 이 리소스에 대한 액세스 권한을 가지는 역할, 사용자, 그리고 머신 클라이언트를 제어합니다. 관리자는 항상 접근할 수 있습니다.",
"editInternalResourceDialogPortRangeValidationError": "모든 포트에 대해서는 \"*\"로, 아니면 쉼표로 구분된 포트 및 범위 목록(예: \"80,443,8000-9000\")을 설정해야 합니다. 포트는 1에서 65535 사이여야 합니다.",
+ "internalResourceAuthDaemonStrategy": "SSH 인증 데몬 위치",
+ "internalResourceAuthDaemonStrategyDescription": "SSH 인증 데몬이 작동하는 위치를 선택하세요: 사이트(Newt)에서 또는 원격 호스트에서.",
+ "internalResourceAuthDaemonDescription": "SSH 인증 데몬은 이 리소스를 위한 SSH 키 서명과 PAM 인증을 처리합니다. 사이트(Newt)에서 나 별도의 원격 호스트에서 실행할 것인지를 선택하세요. 자세한 내용은 문서를 참조하세요.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "전략 선택",
+ "internalResourceAuthDaemonStrategyLabel": "위치",
+ "internalResourceAuthDaemonSite": "사이트에서 인증 데몬이 실행됩니다(Newt).",
+ "internalResourceAuthDaemonSiteDescription": "인증 데몬이 사이트(Newt)에서 실행됩니다.",
+ "internalResourceAuthDaemonRemote": "원격 호스트",
+ "internalResourceAuthDaemonRemoteDescription": "인증 데몬이 사이트가 아닌 다른 호스트에서 실행됩니다.",
+ "internalResourceAuthDaemonPort": "데몬 포트 (선택 사항)",
"orgAuthWhatsThis": "조직 ID를 어디에서 찾을 수 있습니까?",
"learnMore": "자세히 알아보기",
"backToHome": "홈으로 돌아가기",
diff --git a/messages/nb-NO.json b/messages/nb-NO.json
index f86c556a..503f4265 100644
--- a/messages/nb-NO.json
+++ b/messages/nb-NO.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rolle fjernet",
"accessRoleRemovedDescription": "Rollen er vellykket fjernet.",
"accessRoleRequiredRemove": "Før du sletter denne rollen, vennligst velg en ny rolle å overføre eksisterende medlemmer til.",
+ "network": "Nettverk",
"manage": "Administrer",
"sitesNotFound": "Ingen områder funnet.",
"pangolinServerAdmin": "Server Admin - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privat",
"sidebarAccessControl": "Tilgangskontroll",
"sidebarLogsAndAnalytics": "Logger og analyser",
+ "sidebarTeam": "Lag",
"sidebarUsers": "Brukere",
"sidebarAdmin": "Administrator",
"sidebarInvitations": "Invitasjoner",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Logg og analyser",
"sidebarBluePrints": "Tegninger",
"sidebarOrganization": "Organisasjon",
+ "sidebarManagement": "Administrasjon",
"sidebarBillingAndLicenses": "Fakturering & lisenser",
"sidebarLogsAnalytics": "Analyser",
"blueprints": "Tegninger",
@@ -1289,7 +1292,6 @@
"parsedContents": "Parastinnhold (kun lese)",
"enableDockerSocket": "Aktiver Docker blåkopi",
"enableDockerSocketDescription": "Aktiver skraping av Docker Socket for blueprint Etiketter. Socket bane må brukes for nye.",
- "enableDockerSocketLink": "Lær mer",
"viewDockerContainers": "Vis Docker-containere",
"containersIn": "Containere i {siteName}",
"selectContainerDescription": "Velg en hvilken som helst container for å bruke som vertsnavn for dette målet. Klikk på en port for å bruke en port.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Fremhev tilgjengelig varsel",
"billingFeatureLossDescription": "Ved å nedgradere vil funksjoner som ikke er tilgjengelige i den nye planen automatisk bli deaktivert. Noen innstillinger og konfigurasjoner kan gå tapt. Vennligst gjennomgå prismatrisen for å forstå hvilke funksjoner som ikke lenger vil være tilgjengelige.",
"billingUsageExceedsLimit": "Gjeldende bruk ({current}) overskrider grensen ({limit})",
+ "billingPastDueTitle": "Betalingen har forfalt",
+ "billingPastDueDescription": "Betalingen er forfalt. Vennligst oppdater betalingsmetoden din for å fortsette å bruke den gjeldende funksjonsplanen din. Hvis du ikke har løst deg, vil abonnementet ditt avbrytes, og du vil bli tilbakestilt til gratistiden.",
+ "billingUnpaidTitle": "Abonnement ubetalt",
+ "billingUnpaidDescription": "Ditt abonnement er ubetalt og du har blitt tilbakestilt til gratis kasse. Vennligst oppdater din betalingsmetode for å gjenopprette abonnementet.",
+ "billingIncompleteTitle": "Betaling ufullstendig",
+ "billingIncompleteDescription": "Betalingen er ufullstendig. Vennligst fullfør betalingsprosessen for å aktivere abonnementet.",
+ "billingIncompleteExpiredTitle": "Betaling utløpt",
+ "billingIncompleteExpiredDescription": "Din betaling ble aldri fullført, og har utløpt. Du har blitt tilbakestilt til gratis dekk. Vennligst abonner på nytt for å gjenopprette tilgangen til betalte funksjoner.",
+ "billingManageSubscription": "Administrere ditt abonnement",
+ "billingResolvePaymentIssue": "Vennligst løs ditt betalingsproblem før du oppgraderer eller nedgraderer betalingen",
"signUpTerms": {
"IAgreeToThe": "Jeg godtar",
"termsOfService": "brukervilkårene",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Tid er i sekunder",
"requireDeviceApproval": "Krev enhetsgodkjenning",
"requireDeviceApprovalDescription": "Brukere med denne rollen trenger nye enheter godkjent av en admin før de kan koble seg og få tilgang til ressurser.",
+ "sshAccess": "SSH tilgang",
+ "roleAllowSsh": "Tillat SSH",
+ "roleAllowSshAllow": "Tillat",
+ "roleAllowSshDisallow": "Forby",
+ "roleAllowSshDescription": "Tillat brukere med denne rollen å koble til ressurser via SSH. Når deaktivert får rollen ikke tilgang til SSH.",
+ "sshSudoMode": "Sudo tilgang",
+ "sshSudoModeNone": "Ingen",
+ "sshSudoModeNoneDescription": "Brukeren kan ikke kjøre kommandoer med sudo.",
+ "sshSudoModeFull": "Full Sudo",
+ "sshSudoModeFullDescription": "Brukeren kan kjøre hvilken som helst kommando med sudo.",
+ "sshSudoModeCommands": "Kommandoer",
+ "sshSudoModeCommandsDescription": "Brukeren kan bare kjøre de angitte kommandoene med sudo.",
+ "sshSudo": "Tillat sudo",
+ "sshSudoCommands": "Sudo kommandoer",
+ "sshSudoCommandsDescription": "Liste av kommandoer brukeren har lov til å kjøre med sudo.",
+ "sshCreateHomeDir": "Opprett hjemmappe",
+ "sshUnixGroups": "Unix grupper",
+ "sshUnixGroupsDescription": "Unix grupper for å legge til brukeren til målverten.",
"retryAttempts": "Forsøk på nytt",
"expectedResponseCodes": "Forventede svarkoder",
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Tilgangskontroll",
"editInternalResourceDialogAccessControlDescription": "Kontroller hvilke roller, brukere og maskinklienter som har tilgang til denne ressursen når den er koblet til. Administratorer har alltid tilgang.",
"editInternalResourceDialogPortRangeValidationError": "Portsjiktet må være \"*\" for alle porter, eller en kommaseparert liste med porter og sjikt (f.eks. \"80,443,8000-9000\"). Porter må være mellom 1 og 65535.",
+ "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Sted",
+ "internalResourceAuthDaemonStrategyDescription": "Velg hvor SSH-autentisering daemon kjører: på nettstedet (Newt) eller på en ekstern vert.",
+ "internalResourceAuthDaemonDescription": "SSH-godkjenning daemon håndterer SSH-nøkkel signering og PAM autentisering for denne ressursen. Velg om den kjører på nettstedet (Newt) eller på en separat ekstern vert. Se dokumentasjonen for mer.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Velg strategi",
+ "internalResourceAuthDaemonStrategyLabel": "Sted",
+ "internalResourceAuthDaemonSite": "På nettsted",
+ "internalResourceAuthDaemonSiteDescription": "Autentiser daemon kjører på nettstedet (Newt).",
+ "internalResourceAuthDaemonRemote": "Ekstern vert",
+ "internalResourceAuthDaemonRemoteDescription": "Autentiser daemon kjører på en vert som ikke er nettstedet.",
+ "internalResourceAuthDaemonPort": "Daemon Port (valgfritt)",
"orgAuthWhatsThis": "Hvor kan jeg finne min organisasjons-ID?",
"learnMore": "Lær mer",
"backToHome": "Gå tilbake til start",
diff --git a/messages/nl-NL.json b/messages/nl-NL.json
index 4c23dc85..caa2ed17 100644
--- a/messages/nl-NL.json
+++ b/messages/nl-NL.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rol verwijderd",
"accessRoleRemovedDescription": "De rol is succesvol verwijderd.",
"accessRoleRequiredRemove": "Voordat u deze rol verwijdert, selecteer een nieuwe rol om bestaande leden aan te dragen.",
+ "network": "Netwerk",
"manage": "Beheren",
"sitesNotFound": "Geen sites gevonden.",
"pangolinServerAdmin": "Serverbeheer - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privé",
"sidebarAccessControl": "Toegangs controle",
"sidebarLogsAndAnalytics": "Logs & Analytics",
+ "sidebarTeam": "Team",
"sidebarUsers": "Gebruikers",
"sidebarAdmin": "Beheerder",
"sidebarInvitations": "Uitnodigingen",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blauwdrukken",
"sidebarOrganization": "Organisatie",
+ "sidebarManagement": "Beheer",
"sidebarBillingAndLicenses": "Facturatie & Licenties",
"sidebarLogsAnalytics": "Analyses",
"blueprints": "Blauwdrukken",
@@ -1289,7 +1292,6 @@
"parsedContents": "Geparseerde inhoud (alleen lezen)",
"enableDockerSocket": "Schakel Docker Blauwdruk in",
"enableDockerSocketDescription": "Schakel Docker Socket label in voor blauwdruk labels. Pad naar Nieuw.",
- "enableDockerSocketLink": "Meer informatie",
"viewDockerContainers": "Bekijk Docker containers",
"containersIn": "Containers in {siteName}",
"selectContainerDescription": "Selecteer een container om als hostnaam voor dit doel te gebruiken. Klik op een poort om een poort te gebruiken.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Kennisgeving beschikbaarheid",
"billingFeatureLossDescription": "Door downgraden worden functies die niet beschikbaar zijn in het nieuwe abonnement automatisch uitgeschakeld. Sommige instellingen en configuraties kunnen verloren gaan. Raadpleeg de prijsmatrix om te begrijpen welke functies niet langer beschikbaar zijn.",
"billingUsageExceedsLimit": "Huidig gebruik ({current}) overschrijdt limiet ({limit})",
+ "billingPastDueTitle": "Vervaldatum betaling",
+ "billingPastDueDescription": "Uw betaling is verlopen. Werk uw betaalmethode bij om uw huidige abonnementsfuncties te blijven gebruiken. Als dit niet is opgelost, zal je abonnement worden geannuleerd en zal je worden teruggezet naar de vrije rang.",
+ "billingUnpaidTitle": "Abonnement Onbetaald",
+ "billingUnpaidDescription": "Uw abonnement is niet betaald en u bent teruggekeerd naar het gratis niveau. Update uw betalingsmethode om uw abonnement te herstellen.",
+ "billingIncompleteTitle": "Betaling onvolledig",
+ "billingIncompleteDescription": "Uw betaling is onvolledig. Voltooi alstublieft het betalingsproces om uw abonnement te activeren.",
+ "billingIncompleteExpiredTitle": "Betaling verlopen",
+ "billingIncompleteExpiredDescription": "Uw betaling is nooit voltooid en verlopen. U bent teruggekeerd naar de gratis niveaus. Abonneer u opnieuw om de toegang tot betaalde functies te herstellen.",
+ "billingManageSubscription": "Beheer uw abonnement",
+ "billingResolvePaymentIssue": "Gelieve uw betalingsprobleem op te lossen voor het upgraden of downgraden",
"signUpTerms": {
"IAgreeToThe": "Ik ga akkoord met de",
"termsOfService": "servicevoorwaarden",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Tijd is in seconden",
"requireDeviceApproval": "Vereist goedkeuring van apparaat",
"requireDeviceApprovalDescription": "Gebruikers met deze rol hebben nieuwe apparaten nodig die door een beheerder zijn goedgekeurd voordat ze verbinding kunnen maken met bronnen en deze kunnen gebruiken.",
+ "sshAccess": "SSH toegang",
+ "roleAllowSsh": "SSH toestaan",
+ "roleAllowSshAllow": "Toestaan",
+ "roleAllowSshDisallow": "Weigeren",
+ "roleAllowSshDescription": "Sta gebruikers met deze rol toe om verbinding te maken met bronnen via SSH. Indien uitgeschakeld kan de rol geen gebruik maken van SSH toegang.",
+ "sshSudoMode": "Sudo toegang",
+ "sshSudoModeNone": "geen",
+ "sshSudoModeNoneDescription": "Gebruiker kan geen commando's uitvoeren met sudo.",
+ "sshSudoModeFull": "Volledige Sudo",
+ "sshSudoModeFullDescription": "Gebruiker kan elk commando uitvoeren met een sudo.",
+ "sshSudoModeCommands": "Opdrachten",
+ "sshSudoModeCommandsDescription": "Gebruiker kan alleen de opgegeven commando's uitvoeren met de sudo.",
+ "sshSudo": "sudo toestaan",
+ "sshSudoCommands": "Sudo Commando's",
+ "sshSudoCommandsDescription": "Lijst van commando's die de gebruiker mag uitvoeren met een sudo.",
+ "sshCreateHomeDir": "Maak Home Directory",
+ "sshUnixGroups": "Unix groepen",
+ "sshUnixGroupsDescription": "Unix groepen om de gebruiker toe te voegen aan de doel host.",
"retryAttempts": "Herhaal Pogingen",
"expectedResponseCodes": "Verwachte Reactiecodes",
"expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Toegangs controle",
"editInternalResourceDialogAccessControlDescription": "Beheer welke rollen, gebruikers en machineclients toegang hebben tot deze bron wanneer ze zijn verbonden. Beheerders hebben altijd toegang.",
"editInternalResourceDialogPortRangeValidationError": "Poortbereik moet \"*\" zijn voor alle poorten, of een komma-gescheiden lijst van poorten en bereiken (bijv. \"80,443,8000-9000\"). Poorten moeten tussen 1 en 65535 zijn.",
+ "internalResourceAuthDaemonStrategy": "SSH Auth Daemon locatie",
+ "internalResourceAuthDaemonStrategyDescription": "Kies waar de SSH authenticatie daemon wordt uitgevoerd: op de website (Newt) of op een externe host.",
+ "internalResourceAuthDaemonDescription": "De SSH authenticatie daemon zorgt voor SSH sleutelondertekening en PAM authenticatie voor deze resource. Kies of het wordt uitgevoerd op de website (Nieuw) of op een afzonderlijke externe host. Zie de documentatie voor meer.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Selecteer strategie",
+ "internalResourceAuthDaemonStrategyLabel": "Locatie",
+ "internalResourceAuthDaemonSite": "In de site",
+ "internalResourceAuthDaemonSiteDescription": "Auth daemon draait op de site (Newt).",
+ "internalResourceAuthDaemonRemote": "Externe host",
+ "internalResourceAuthDaemonRemoteDescription": "Authenticatiedaemon draait op een host die niet de site is.",
+ "internalResourceAuthDaemonPort": "Daemon poort (optioneel)",
"orgAuthWhatsThis": "Waar kan ik mijn organisatie-ID vinden?",
"learnMore": "Meer informatie",
"backToHome": "Ga terug naar startpagina",
diff --git a/messages/pl-PL.json b/messages/pl-PL.json
index 84052ce9..6203f4cc 100644
--- a/messages/pl-PL.json
+++ b/messages/pl-PL.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rola usunięta",
"accessRoleRemovedDescription": "Rola została pomyślnie usunięta.",
"accessRoleRequiredRemove": "Przed usunięciem tej roli, wybierz nową rolę do której zostaną przeniesieni obecni członkowie.",
+ "network": "Sieć",
"manage": "Zarządzaj",
"sitesNotFound": "Nie znaleziono witryn.",
"pangolinServerAdmin": "Administrator serwera - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Prywatny",
"sidebarAccessControl": "Kontrola dostępu",
"sidebarLogsAndAnalytics": "Logi i Analityki",
+ "sidebarTeam": "Drużyna",
"sidebarUsers": "Użytkownicy",
"sidebarAdmin": "Administrator",
"sidebarInvitations": "Zaproszenia",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Dziennik & Analityka",
"sidebarBluePrints": "Schematy",
"sidebarOrganization": "Organizacja",
+ "sidebarManagement": "Zarządzanie",
"sidebarBillingAndLicenses": "Płatność i licencje",
"sidebarLogsAnalytics": "Analityka",
"blueprints": "Schematy",
@@ -1289,7 +1292,6 @@
"parsedContents": "Przetworzona zawartość (tylko do odczytu)",
"enableDockerSocket": "Włącz schemat dokera",
"enableDockerSocketDescription": "Włącz etykietowanie kieszeni dokującej dla etykiet schematów. Ścieżka do gniazda musi być dostarczona do Newt.",
- "enableDockerSocketLink": "Dowiedz się więcej",
"viewDockerContainers": "Zobacz kontenery dokujące",
"containersIn": "Pojemniki w {siteName}",
"selectContainerDescription": "Wybierz dowolny kontener do użycia jako nazwa hosta dla tego celu. Kliknij port, aby użyć portu.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Powiadomienie o dostępności funkcji",
"billingFeatureLossDescription": "Po obniżeniu wartości funkcje niedostępne w nowym planie zostaną automatycznie wyłączone. Niektóre ustawienia i konfiguracje mogą zostać utracone. Zapoznaj się z matrycą cenową, aby zrozumieć, które funkcje nie będą już dostępne.",
"billingUsageExceedsLimit": "Bieżące użycie ({current}) przekracza limit ({limit})",
+ "billingPastDueTitle": "Płatność w przeszłości",
+ "billingPastDueDescription": "Twoja płatność jest zaległa. Zaktualizuj metodę płatności, aby kontynuować korzystanie z funkcji aktualnego planu. Jeśli nie zostanie rozwiązana, Twoja subskrypcja zostanie anulowana i zostaniesz przywrócony do darmowego poziomu.",
+ "billingUnpaidTitle": "Subskrypcja niezapłacona",
+ "billingUnpaidDescription": "Twoja subskrypcja jest niezapłacona i została przywrócona do darmowego poziomu. Zaktualizuj swoją metodę płatności, aby przywrócić subskrypcję.",
+ "billingIncompleteTitle": "Płatność niezakończona",
+ "billingIncompleteDescription": "Twoja płatność jest niekompletna. Ukończ proces płatności, aby aktywować subskrypcję.",
+ "billingIncompleteExpiredTitle": "Płatność wygasła",
+ "billingIncompleteExpiredDescription": "Twoja płatność nigdy nie została zakończona i wygasła. Zostałeś przywrócony do darmowego poziomu. Zapisz się ponownie, aby przywrócić dostęp do płatnych funkcji.",
+ "billingManageSubscription": "Zarządzaj subskrypcją",
+ "billingResolvePaymentIssue": "Rozwiąż problem z płatnościami przed aktualizacją lub obniżeniem oceny",
"signUpTerms": {
"IAgreeToThe": "Zgadzam się z",
"termsOfService": "warunkami usługi",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Czas w sekundach",
"requireDeviceApproval": "Wymagaj zatwierdzenia urządzenia",
"requireDeviceApprovalDescription": "Użytkownicy o tej roli potrzebują nowych urządzeń zatwierdzonych przez administratora, zanim będą mogli połączyć się i uzyskać dostęp do zasobów.",
+ "sshAccess": "Dostęp SSH",
+ "roleAllowSsh": "Zezwalaj na SSH",
+ "roleAllowSshAllow": "Zezwól",
+ "roleAllowSshDisallow": "Nie zezwalaj",
+ "roleAllowSshDescription": "Zezwalaj użytkownikom z tej roli na łączenie się z zasobami za pomocą SSH. Gdy wyłączone, rola nie może korzystać z dostępu SSH.",
+ "sshSudoMode": "Dostęp Sudo",
+ "sshSudoModeNone": "Brak",
+ "sshSudoModeNoneDescription": "Użytkownik nie może uruchamiać poleceń z sudo.",
+ "sshSudoModeFull": "Pełne Sudo",
+ "sshSudoModeFullDescription": "Użytkownik może uruchomić dowolne polecenie z sudo.",
+ "sshSudoModeCommands": "Polecenia",
+ "sshSudoModeCommandsDescription": "Użytkownik może uruchamiać tylko określone polecenia z sudo.",
+ "sshSudo": "Zezwól na sudo",
+ "sshSudoCommands": "Komendy Sudo",
+ "sshSudoCommandsDescription": "Lista poleceń, które użytkownik może uruchamiać z sudo.",
+ "sshCreateHomeDir": "Utwórz katalog domowy",
+ "sshUnixGroups": "Grupy Unix",
+ "sshUnixGroupsDescription": "Grupy Unix do dodania użytkownika do docelowego hosta.",
"retryAttempts": "Próby Ponowienia",
"expectedResponseCodes": "Oczekiwane Kody Odpowiedzi",
"expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Kontrola dostępu",
"editInternalResourceDialogAccessControlDescription": "Kontroluj, które role, użytkownicy i klienci maszyn mają dostęp do tego zasobu po połączeniu. Administratorzy zawsze mają dostęp.",
"editInternalResourceDialogPortRangeValidationError": "Zakres portów musi być \"*\" dla wszystkich portów lub listą portów i zakresów oddzielonych przecinkami (np. \"80,443,8000-9000\"). Porty muszą znajdować się w przedziale od 1 do 65535.",
+ "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Lokalizacja",
+ "internalResourceAuthDaemonStrategyDescription": "Wybierz, gdzie działa demon uwierzytelniania SSH: na stronie (Newt) lub na zdalnym serwerze.",
+ "internalResourceAuthDaemonDescription": "Uwierzytelnianie SSH obsługuje podpisywanie klucza SSH i uwierzytelnianie PAM dla tego zasobu. Wybierz, czy działa na stronie (Newt), czy na oddzielnym serwerze zdalnym. Zobacz dokumentację dla więcej.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Wybierz strategię",
+ "internalResourceAuthDaemonStrategyLabel": "Lokalizacja",
+ "internalResourceAuthDaemonSite": "Na stronie",
+ "internalResourceAuthDaemonSiteDescription": "Demon Auth działa na stronie (nowy).",
+ "internalResourceAuthDaemonRemote": "Zdalny host",
+ "internalResourceAuthDaemonRemoteDescription": "Demon Auth działa na serwerze, który nie jest stroną.",
+ "internalResourceAuthDaemonPort": "Port Daemon (opcjonalnie)",
"orgAuthWhatsThis": "Gdzie mogę znaleźć swój identyfikator organizacji?",
"learnMore": "Dowiedz się więcej",
"backToHome": "Wróć do strony głównej",
diff --git a/messages/pt-PT.json b/messages/pt-PT.json
index 4cfac3c2..b623b2b2 100644
--- a/messages/pt-PT.json
+++ b/messages/pt-PT.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Função removida",
"accessRoleRemovedDescription": "A função foi removida com sucesso.",
"accessRoleRequiredRemove": "Antes de apagar esta função, selecione uma nova função para transferir os membros existentes.",
+ "network": "Rede",
"manage": "Gerir",
"sitesNotFound": "Nenhum site encontrado.",
"pangolinServerAdmin": "Administrador do Servidor - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privado",
"sidebarAccessControl": "Controle de Acesso",
"sidebarLogsAndAnalytics": "Registros e Análises",
+ "sidebarTeam": "Equipe",
"sidebarUsers": "Utilizadores",
"sidebarAdmin": "Administrador",
"sidebarInvitations": "Convites",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Registo & Análise",
"sidebarBluePrints": "Diagramas",
"sidebarOrganization": "Organização",
+ "sidebarManagement": "Gestão",
"sidebarBillingAndLicenses": "Faturamento e Licenças",
"sidebarLogsAnalytics": "Análises",
"blueprints": "Diagramas",
@@ -1289,7 +1292,6 @@
"parsedContents": "Conteúdo analisado (Somente Leitura)",
"enableDockerSocket": "Habilitar o Diagrama Docker",
"enableDockerSocketDescription": "Ativar a scraping de rótulo Docker para rótulos de diagramas. Caminho de Socket deve ser fornecido para Newt.",
- "enableDockerSocketLink": "Saiba mais",
"viewDockerContainers": "Ver contêineres Docker",
"containersIn": "Contêineres em {siteName}",
"selectContainerDescription": "Selecione qualquer contêiner para usar como hostname para este alvo. Clique em uma porta para usar uma porta.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Aviso de disponibilidade de recursos",
"billingFeatureLossDescription": "Ao fazer o downgrading, recursos não disponíveis no novo plano serão desativados automaticamente. Algumas configurações e configurações podem ser perdidas. Por favor, revise a matriz de preços para entender quais características não estarão mais disponíveis.",
"billingUsageExceedsLimit": "Uso atual ({current}) excede o limite ({limit})",
+ "billingPastDueTitle": "Pagamento passado devido",
+ "billingPastDueDescription": "Seu pagamento está vencido. Por favor, atualize seu método de pagamento para continuar usando os recursos do seu plano atual. Se não for resolvido, sua assinatura será cancelada e você será revertido para o nível gratuito.",
+ "billingUnpaidTitle": "Assinatura não paga",
+ "billingUnpaidDescription": "Sua assinatura não foi paga e você voltou para o nível gratuito. Atualize o seu método de pagamento para restaurar sua assinatura.",
+ "billingIncompleteTitle": "Pagamento Incompleto",
+ "billingIncompleteDescription": "Seu pagamento está incompleto. Por favor, complete o processo de pagamento para ativar sua assinatura.",
+ "billingIncompleteExpiredTitle": "Pagamento expirado",
+ "billingIncompleteExpiredDescription": "Seu pagamento nunca foi concluído e expirou. Você foi revertido para o nível gratuito. Por favor, inscreva-se novamente para restaurar o acesso a recursos pagos.",
+ "billingManageSubscription": "Gerencie sua assinatura",
+ "billingResolvePaymentIssue": "Por favor, resolva seu problema de pagamento antes de atualizar ou rebaixar",
"signUpTerms": {
"IAgreeToThe": "Concordo com",
"termsOfService": "os termos de serviço",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "O tempo está em segundos",
"requireDeviceApproval": "Exigir aprovação do dispositivo",
"requireDeviceApprovalDescription": "Usuários com esta função precisam de novos dispositivos aprovados por um administrador antes que eles possam se conectar e acessar recursos.",
+ "sshAccess": "Acesso SSH",
+ "roleAllowSsh": "Permitir SSH",
+ "roleAllowSshAllow": "Autorizar",
+ "roleAllowSshDisallow": "Anular",
+ "roleAllowSshDescription": "Permitir que usuários com esta função se conectem a recursos via SSH. Quando desativado, a função não pode usar o acesso SSH.",
+ "sshSudoMode": "Acesso Sudo",
+ "sshSudoModeNone": "Nenhuma",
+ "sshSudoModeNoneDescription": "O usuário não pode executar comandos com o sudo.",
+ "sshSudoModeFull": "Sudo Completo",
+ "sshSudoModeFullDescription": "O usuário pode executar qualquer comando com sudo.",
+ "sshSudoModeCommands": "Comandos",
+ "sshSudoModeCommandsDescription": "Usuário só pode executar os comandos especificados com sudo.",
+ "sshSudo": "Permitir sudo",
+ "sshSudoCommands": "Comandos Sudo",
+ "sshSudoCommandsDescription": "Lista de comandos com permissão de executar com o sudo.",
+ "sshCreateHomeDir": "Criar Diretório Inicial",
+ "sshUnixGroups": "Grupos Unix",
+ "sshUnixGroupsDescription": "Grupos Unix para adicionar o usuário no host de destino.",
"retryAttempts": "Tentativas de Repetição",
"expectedResponseCodes": "Códigos de Resposta Esperados",
"expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Controle de Acesso",
"editInternalResourceDialogAccessControlDescription": "Controle quais funções, usuários e clientes de máquina podem acessar este recurso quando conectados. Os administradores sempre têm acesso.",
"editInternalResourceDialogPortRangeValidationError": "O intervalo de portas deve ser \"*\" para todas as portas, ou uma lista de portas e intervalos separados por vírgulas (por exemplo, \"80,443,8000-9000\"). As portas devem estar entre 1 e 65535.",
+ "internalResourceAuthDaemonStrategy": "Local do Daemon de autenticação SSH",
+ "internalResourceAuthDaemonStrategyDescription": "Escolha onde o daemon de autenticação SSH funciona: no site (Newt) ou em um host remoto.",
+ "internalResourceAuthDaemonDescription": "A autenticação SSH daemon lida com assinatura de chave SSH e autenticação PAM para este recurso. Escolha se ele é executado no site (Newt) ou em um host remoto separado. Veja a documentação para mais informações.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Selecione a estratégia",
+ "internalResourceAuthDaemonStrategyLabel": "Local:",
+ "internalResourceAuthDaemonSite": "No Site",
+ "internalResourceAuthDaemonSiteDescription": "O serviço de autenticação é executado no site (Newt).",
+ "internalResourceAuthDaemonRemote": "Host Remoto",
+ "internalResourceAuthDaemonRemoteDescription": "O serviço de autenticação é executado em um host que não é o site.",
+ "internalResourceAuthDaemonPort": "Porta do Daemon (opcional)",
"orgAuthWhatsThis": "Onde posso encontrar meu ID da organização?",
"learnMore": "Saiba mais",
"backToHome": "Voltar para a página inicial",
diff --git a/messages/ru-RU.json b/messages/ru-RU.json
index 1ecf87d8..f4dd0ac3 100644
--- a/messages/ru-RU.json
+++ b/messages/ru-RU.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Роль удалена",
"accessRoleRemovedDescription": "Роль была успешно удалена.",
"accessRoleRequiredRemove": "Перед удалением этой роли выберите новую роль для переноса существующих участников.",
+ "network": "Сеть",
"manage": "Управление",
"sitesNotFound": "Сайты не найдены.",
"pangolinServerAdmin": "Администратор сервера - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Приватный",
"sidebarAccessControl": "Контроль доступа",
"sidebarLogsAndAnalytics": "Журналы и аналитика",
+ "sidebarTeam": "Команда",
"sidebarUsers": "Пользователи",
"sidebarAdmin": "Админ",
"sidebarInvitations": "Приглашения",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Журнал и аналитика",
"sidebarBluePrints": "Чертежи",
"sidebarOrganization": "Организация",
+ "sidebarManagement": "Управление",
"sidebarBillingAndLicenses": "Биллинг и лицензии",
"sidebarLogsAnalytics": "Статистика",
"blueprints": "Чертежи",
@@ -1289,7 +1292,6 @@
"parsedContents": "Переработанное содержимое (только для чтения)",
"enableDockerSocket": "Включить чертёж Docker",
"enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.",
- "enableDockerSocketLink": "Узнать больше",
"viewDockerContainers": "Просмотр контейнеров Docker",
"containersIn": "Контейнеры в {siteName}",
"selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Уведомление о доступности функций",
"billingFeatureLossDescription": "При переходе на другой тарифный план функции не будут автоматически отключены. Некоторые настройки и конфигурации могут быть потеряны. Пожалуйста, ознакомьтесь с матрицей ценообразования, чтобы понять, какие функции больше не будут доступны.",
"billingUsageExceedsLimit": "Текущее использование ({current}) превышает предел ({limit})",
+ "billingPastDueTitle": "Платеж просрочен",
+ "billingPastDueDescription": "Ваш платеж просрочен. Пожалуйста, обновите способ оплаты, чтобы продолжить использовать текущие функции. Если ваша подписка не будет решена, она будет отменена, и вы вернетесь к бесплатному уровню.",
+ "billingUnpaidTitle": "Подписка не оплачена",
+ "billingUnpaidDescription": "Ваша подписка не оплачена, и вы были возвращены к бесплатному уровню. Пожалуйста, обновите способ оплаты, чтобы восстановить вашу подписку.",
+ "billingIncompleteTitle": "Платеж не завершен",
+ "billingIncompleteDescription": "Ваш платеж не завершен. Пожалуйста, завершите процесс оплаты, чтобы активировать вашу подписку.",
+ "billingIncompleteExpiredTitle": "Платеж просрочен",
+ "billingIncompleteExpiredDescription": "Ваш платеж не был завершен и истек. Вы были возвращены к бесплатному уровню. Пожалуйста, подпишитесь снова, чтобы восстановить доступ к платным функциям.",
+ "billingManageSubscription": "Управление подпиской",
+ "billingResolvePaymentIssue": "Пожалуйста, решите проблему оплаты перед обновлением или понижением сорта",
"signUpTerms": {
"IAgreeToThe": "Я согласен с",
"termsOfService": "условия использования",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Время указано в секундах",
"requireDeviceApproval": "Требовать подтверждения устройства",
"requireDeviceApprovalDescription": "Пользователям с этой ролью нужны новые устройства, одобренные администратором, прежде чем они смогут подключаться и получать доступ к ресурсам.",
+ "sshAccess": "SSH доступ",
+ "roleAllowSsh": "Разрешить SSH",
+ "roleAllowSshAllow": "Разрешить",
+ "roleAllowSshDisallow": "Запретить",
+ "roleAllowSshDescription": "Разрешить пользователям с этой ролью подключаться к ресурсам через SSH. Если отключено, роль не может использовать доступ SSH.",
+ "sshSudoMode": "Sudo доступ",
+ "sshSudoModeNone": "Нет",
+ "sshSudoModeNoneDescription": "Пользователь не может запускать команды с sudo.",
+ "sshSudoModeFull": "Полная судо",
+ "sshSudoModeFullDescription": "Пользователь может запускать любую команду с помощью sudo.",
+ "sshSudoModeCommands": "Команды",
+ "sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.",
+ "sshSudo": "Разрешить sudo",
+ "sshSudoCommands": "Sudo Команды",
+ "sshSudoCommandsDescription": "Список команд, которые пользователю разрешено запускать с помощью sudo.",
+ "sshCreateHomeDir": "Создать домашний каталог",
+ "sshUnixGroups": "Unix группы",
+ "sshUnixGroupsDescription": "Unix группы для добавления пользователя на целевой хост.",
"retryAttempts": "Количество попыток повторного запроса",
"expectedResponseCodes": "Ожидаемые коды ответов",
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Контроль доступа",
"editInternalResourceDialogAccessControlDescription": "Контролируйте, какие роли, пользователи и машинные клиенты имеют доступ к этому ресурсу при подключении. Администраторы всегда имеют доступ.",
"editInternalResourceDialogPortRangeValidationError": "Диапазон портов должен быть \"*\" для всех портов или списком портов и диапазонов через запятую (например, \"80,443,8000-9000\"). Порты должны находиться в диапазоне от 1 до 65535.",
+ "internalResourceAuthDaemonStrategy": "Местоположение демона по SSH",
+ "internalResourceAuthDaemonStrategyDescription": "Выберите, где работает демон аутентификации SSH: на сайте (Newt) или на удаленном узле.",
+ "internalResourceAuthDaemonDescription": "Демон аутентификации SSH обрабатывает подписание ключей SSH и аутентификацию PAM для этого ресурса. Выберите, запускать ли его на сайте (Newt) или на отдельном удаленном хосте. Подробности смотрите в документации.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Выберите стратегию",
+ "internalResourceAuthDaemonStrategyLabel": "Местоположение",
+ "internalResourceAuthDaemonSite": "На сайте",
+ "internalResourceAuthDaemonSiteDescription": "На сайте работает демон Auth (Newt).",
+ "internalResourceAuthDaemonRemote": "Удаленный хост",
+ "internalResourceAuthDaemonRemoteDescription": "Демон Auth запускается на хост, который не является сайтом.",
+ "internalResourceAuthDaemonPort": "Порт демона (опционально)",
"orgAuthWhatsThis": "Где я могу найти ID моей организации?",
"learnMore": "Узнать больше",
"backToHome": "Вернуться домой",
diff --git a/messages/tr-TR.json b/messages/tr-TR.json
index 7fb13369..f853629d 100644
--- a/messages/tr-TR.json
+++ b/messages/tr-TR.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rol kaldırıldı",
"accessRoleRemovedDescription": "Rol başarıyla kaldırıldı.",
"accessRoleRequiredRemove": "Bu rolü silmeden önce, mevcut üyeleri aktarmak için yeni bir rol seçin.",
+ "network": "Ağ",
"manage": "Yönet",
"sitesNotFound": "Site bulunamadı.",
"pangolinServerAdmin": "Sunucu Yöneticisi - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Özel",
"sidebarAccessControl": "Erişim Kontrolü",
"sidebarLogsAndAnalytics": "Kayıtlar & Analitik",
+ "sidebarTeam": "Ekip",
"sidebarUsers": "Kullanıcılar",
"sidebarAdmin": "Yönetici",
"sidebarInvitations": "Davetiye",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Kayıt & Analiz",
"sidebarBluePrints": "Planlar",
"sidebarOrganization": "Organizasyon",
+ "sidebarManagement": "Yönetim",
"sidebarBillingAndLicenses": "Faturalandırma & Lisanslar",
"sidebarLogsAnalytics": "Analitik",
"blueprints": "Planlar",
@@ -1289,7 +1292,6 @@
"parsedContents": "Verilerin Ayrıştırılmış İçeriği (Salt Okunur)",
"enableDockerSocket": "Docker Soketini Etkinleştir",
"enableDockerSocketDescription": "Plan etiketleri için Docker Socket etiket toplamasını etkinleştirin. Newt'e soket yolu sağlanmalıdır.",
- "enableDockerSocketLink": "Daha fazla bilgi",
"viewDockerContainers": "Docker Konteynerlerini Görüntüle",
"containersIn": "{siteName} içindeki konteynerler",
"selectContainerDescription": "Bu hedef için bir ana bilgisayar adı olarak kullanmak üzere herhangi bir konteyner seçin. Bir bağlantı noktası kullanmak için bir bağlantı noktasına tıklayın.",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "Özellik Kullanılabilirlik Bildirimi",
"billingFeatureLossDescription": "Plan düşürüldüğünde, yeni planda mevcut olmayan özellikler otomatik olarak devre dışı bırakılacaktır. Bazı ayarlar ve yapılar kaybolabilir. Hangi özelliklerin artık mevcut olmayacağını anlamak için fiyat tablosunu inceleyiniz.",
"billingUsageExceedsLimit": "Mevcut kullanım ({current}) limitleri ({limit}) aşıyor",
+ "billingPastDueTitle": "Ödeme Geçmiş",
+ "billingPastDueDescription": "Ödemenizın vadesi geçti. Mevcut plan özelliklerinizi kullanmaya devam etmek için lütfen ödeme yöntemini güncelleyin. Sorun çözülmezse aboneliğiniz iptal edilecek ve ücretsiz seviyeye dönüleceksiniz.",
+ "billingUnpaidTitle": "Ödenmemiş Abonelik",
+ "billingUnpaidDescription": "Aboneliğiniz ödenmedi ve ücretsiz seviyeye geri döndünüz. Aboneliğinizi geri yüklemek için lütfen ödeme yöntemini güncelleyin.",
+ "billingIncompleteTitle": "Eksik Ödeme",
+ "billingIncompleteDescription": "Ödemeniz eksik. Aboneliğinizi etkinleştirmek için lütfen ödeme sürecini tamamlayın.",
+ "billingIncompleteExpiredTitle": "Ödeme Süresi Doldu",
+ "billingIncompleteExpiredDescription": "Ödemeniz hiç tamamlanmadı ve süresi doldu. Ücretsiz seviyeye geri döndünüz. Ücretli özelliklere erişimi yeniden sağlamak için lütfen yeniden abone olun.",
+ "billingManageSubscription": "Aboneliğinizi Yönetin",
+ "billingResolvePaymentIssue": "Yükseltmeden veya düşürmeden önce ödeme sorunuzu çözün",
"signUpTerms": {
"IAgreeToThe": "Kabul ediyorum",
"termsOfService": "hizmet şartları",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "Zaman saniye cinsindendir",
"requireDeviceApproval": "Cihaz Onaylarını Gerektir",
"requireDeviceApprovalDescription": "Bu role sahip kullanıcıların yeni cihazlarının bağlanabilmesi ve kaynaklara erişebilmesi için bir yönetici tarafından onaylanması gerekiyor.",
+ "sshAccess": "SSH Erişimi",
+ "roleAllowSsh": "SSH'a İzin Ver",
+ "roleAllowSshAllow": "İzin Ver",
+ "roleAllowSshDisallow": "İzin Verme",
+ "roleAllowSshDescription": "Bu role sahip kullanıcıların SSH aracılığıyla kaynaklara bağlanmasına izin verin. Devre dışı bırakıldığında, rol SSH erişimini kullanamaz.",
+ "sshSudoMode": "Sudo Erişimi",
+ "sshSudoModeNone": "Hiçbiri",
+ "sshSudoModeNoneDescription": "Kullanıcı, sudo komutunu kullanarak komut çalıştıramaz.",
+ "sshSudoModeFull": "Tam Sudo",
+ "sshSudoModeFullDescription": "Kullanıcı, sudo komutuyla her türlü komutu çalıştırabilir.",
+ "sshSudoModeCommands": "Komutlar",
+ "sshSudoModeCommandsDescription": "Kullanıcı sadece belirtilen komutları sudo ile çalıştırabilir.",
+ "sshSudo": "Sudo'ya izin ver",
+ "sshSudoCommands": "Sudo Komutları",
+ "sshSudoCommandsDescription": "Kullanıcının sudo ile çalıştırmasına izin verilen komutların listesi.",
+ "sshCreateHomeDir": "Ev Dizini Oluştur",
+ "sshUnixGroups": "Unix Grupları",
+ "sshUnixGroupsDescription": "Hedef ana bilgisayarda kullanıcıya eklemek için Unix grupları.",
"retryAttempts": "Tekrar Deneme Girişimleri",
"expectedResponseCodes": "Beklenen Yanıt Kodları",
"expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "Erişim Kontrolü",
"editInternalResourceDialogAccessControlDescription": "Bağlandığında bu kaynağa erişimi olan roller, kullanıcılar ve makine müşterilerini kontrol edin. Yöneticiler her zaman erişime sahiptir.",
"editInternalResourceDialogPortRangeValidationError": "Port aralığı, tüm portlar için \"*\" veya virgülle ayrılmış bir port ve aralık listesi olmalıdır (ör. \"80,443,8000-9000\"). Portlar 1 ile 65535 arasında olmalıdır.",
+ "internalResourceAuthDaemonStrategy": "SSH Kimlik Doğrulama Daemon Yeri",
+ "internalResourceAuthDaemonStrategyDescription": "SSH kimlik doğrulama sunucusunun nerede çalışacağını seçin: sitede (Newt) veya uzak bir ana bilgisayarda.",
+ "internalResourceAuthDaemonDescription": "SSH kimlik doğrulama sunucusu, bu kaynak için SSH anahtar imzalama ve PAM kimlik doğrulamasını yapar. Sitede (Newt) veya ayrı bir uzak ana bilgisayarda çalışıp çalışmayacağını seçin. Daha fazla bilgi için belgeleri görün.",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "Strateji Seçin",
+ "internalResourceAuthDaemonStrategyLabel": "Konum",
+ "internalResourceAuthDaemonSite": "Sitede",
+ "internalResourceAuthDaemonSiteDescription": "Kimlik doğrulama sunucusu sitede (Newt) çalışır.",
+ "internalResourceAuthDaemonRemote": "Uzak Ana Bilgisayar",
+ "internalResourceAuthDaemonRemoteDescription": "Kimlik doğrulama sunucusu, site olmayan bir ana bilgisayarda çalışır.",
+ "internalResourceAuthDaemonPort": "Daemon Portu (isteğe bağlı)",
"orgAuthWhatsThis": "Kuruluş kimliğimi nerede bulabilirim?",
"learnMore": "Daha fazla bilgi",
"backToHome": "Ana sayfaya geri dön",
diff --git a/messages/zh-CN.json b/messages/zh-CN.json
index 1542bfcd..29fe8039 100644
--- a/messages/zh-CN.json
+++ b/messages/zh-CN.json
@@ -790,6 +790,7 @@
"accessRoleRemoved": "角色已删除",
"accessRoleRemovedDescription": "角色已成功删除。",
"accessRoleRequiredRemove": "删除此角色之前,请选择一个新角色来转移现有成员。",
+ "network": "网络",
"manage": "管理",
"sitesNotFound": "未找到站点。",
"pangolinServerAdmin": "服务器管理员 - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "非公开的",
"sidebarAccessControl": "访问控制",
"sidebarLogsAndAnalytics": "日志与分析",
+ "sidebarTeam": "团队",
"sidebarUsers": "用户",
"sidebarAdmin": "管理员",
"sidebarInvitations": "邀请",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "日志与分析",
"sidebarBluePrints": "蓝图",
"sidebarOrganization": "组织",
+ "sidebarManagement": "管理",
"sidebarBillingAndLicenses": "帐单和许可证",
"sidebarLogsAnalytics": "分析",
"blueprints": "蓝图",
@@ -1289,7 +1292,6 @@
"parsedContents": "解析内容 (只读)",
"enableDockerSocket": "启用 Docker 蓝图",
"enableDockerSocketDescription": "启用 Docker Socket 标签擦除蓝图标签。套接字路径必须提供给新的。",
- "enableDockerSocketLink": "了解更多",
"viewDockerContainers": "查看停靠容器",
"containersIn": "{siteName} 中的容器",
"selectContainerDescription": "选择任何容器作为目标的主机名。点击端口使用端口。",
@@ -1570,6 +1572,16 @@
"billingFeatureLossWarning": "功能可用通知",
"billingFeatureLossDescription": "如果降级,新计划中不可用的功能将被自动禁用。一些设置和配置可能会丢失。 请查看定价矩阵以了解哪些功能将不再可用。",
"billingUsageExceedsLimit": "当前使用量 ({current}) 超出限制 ({limit})",
+ "billingPastDueTitle": "过去到期的付款",
+ "billingPastDueDescription": "您的付款已过期。请更新您的付款方法以继续使用您当前的计划功能。 如果不解决,您的订阅将被取消,您将被恢复到免费等级。",
+ "billingUnpaidTitle": "订阅未付款",
+ "billingUnpaidDescription": "您的订阅未付,您已恢复到免费等级。请更新您的付款方法以恢复您的订阅。",
+ "billingIncompleteTitle": "付款不完成",
+ "billingIncompleteDescription": "您的付款不完整。请完成付款过程以激活您的订阅。",
+ "billingIncompleteExpiredTitle": "付款已过期",
+ "billingIncompleteExpiredDescription": "您的付款尚未完成且已过期。您已恢复到免费级别。请再次订阅以恢复对已支付功能的访问。",
+ "billingManageSubscription": "管理您的订阅",
+ "billingResolvePaymentIssue": "请在升级或降级之前解决您的付款问题",
"signUpTerms": {
"IAgreeToThe": "我同意",
"termsOfService": "服务条款",
@@ -1643,6 +1655,24 @@
"timeIsInSeconds": "时间以秒为单位",
"requireDeviceApproval": "需要设备批准",
"requireDeviceApprovalDescription": "具有此角色的用户需要管理员批准的新设备才能连接和访问资源。",
+ "sshAccess": "SSH 访问",
+ "roleAllowSsh": "允许 SSH",
+ "roleAllowSshAllow": "允许",
+ "roleAllowSshDisallow": "不允许",
+ "roleAllowSshDescription": "允许具有此角色的用户通过 SSH 连接到资源。禁用时,角色不能使用 SSH 访问。",
+ "sshSudoMode": "Sudo 访问",
+ "sshSudoModeNone": "无",
+ "sshSudoModeNoneDescription": "用户不能用sudo运行命令。",
+ "sshSudoModeFull": "全苏多",
+ "sshSudoModeFullDescription": "用户可以用 sudo 运行任何命令。",
+ "sshSudoModeCommands": "命令",
+ "sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
+ "sshSudo": "允许Sudo",
+ "sshSudoCommands": "Sudo 命令",
+ "sshSudoCommandsDescription": "允许用户使用 sudo 运行的命令列表。",
+ "sshCreateHomeDir": "创建主目录",
+ "sshUnixGroups": "Unix 组",
+ "sshUnixGroupsDescription": "将用户添加到目标主机的Unix组。",
"retryAttempts": "重试次数",
"expectedResponseCodes": "期望响应代码",
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
@@ -2503,6 +2533,17 @@
"editInternalResourceDialogAccessControl": "访问控制",
"editInternalResourceDialogAccessControlDescription": "控制当连接到此资源时,哪些角色、用户和机器客户端可以访问。管理员始终具有访问权。",
"editInternalResourceDialogPortRangeValidationError": "端口范围必须为\"*\"表示所有端口,或一个用逗号分隔的端口和范围列表(例如:\"80,443,8000-9000\")。端口必须在1到65535之间。",
+ "internalResourceAuthDaemonStrategy": "SSH 认证守护进程位置",
+ "internalResourceAuthDaemonStrategyDescription": "选择 SSH 身份验证守护进程在哪里运行:站点(新建) 或远程主机。",
+ "internalResourceAuthDaemonDescription": "SSH 身份验证守护程序处理此资源的 SSH 密钥签名和PAM 身份验证。 选择它是在站点(新建)还是在单独的远程主机上运行。请参阅 文档。",
+ "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
+ "internalResourceAuthDaemonStrategyPlaceholder": "选择策略",
+ "internalResourceAuthDaemonStrategyLabel": "地点",
+ "internalResourceAuthDaemonSite": "在站点",
+ "internalResourceAuthDaemonSiteDescription": "认证守护进程在站点上运行(新建)。",
+ "internalResourceAuthDaemonRemote": "远程主机",
+ "internalResourceAuthDaemonRemoteDescription": "认证守护进程运行在不是站点的主机上。",
+ "internalResourceAuthDaemonPort": "守护进程端口(可选)",
"orgAuthWhatsThis": "我的组织ID在哪里可以找到?",
"learnMore": "了解更多",
"backToHome": "返回首页",
diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts
index 73b220fa..f6cae441 100644
--- a/server/auth/sessions/app.ts
+++ b/server/auth/sessions/app.ts
@@ -3,7 +3,14 @@ import {
encodeHexLowerCase
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
-import { resourceSessions, Session, sessions, User, users } from "@server/db";
+import {
+ resourceSessions,
+ safeRead,
+ Session,
+ sessions,
+ User,
+ users
+} from "@server/db";
import { db } from "@server/db";
import { eq, inArray } from "drizzle-orm";
import config from "@server/lib/config";
@@ -54,11 +61,15 @@ export async function validateSessionToken(
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token))
);
- const result = await db
- .select({ user: users, session: sessions })
- .from(sessions)
- .innerJoin(users, eq(sessions.userId, users.userId))
- .where(eq(sessions.sessionId, sessionId));
+
+ const result = await safeRead((db) =>
+ db
+ .select({ user: users, session: sessions })
+ .from(sessions)
+ .innerJoin(users, eq(sessions.userId, users.userId))
+ .where(eq(sessions.sessionId, sessionId))
+ );
+
if (result.length < 1) {
return { session: null, user: null };
}
diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts
index 9a5b2b5f..3b9da3d7 100644
--- a/server/auth/sessions/resource.ts
+++ b/server/auth/sessions/resource.ts
@@ -1,7 +1,7 @@
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { resourceSessions, ResourceSession } from "@server/db";
-import { db } from "@server/db";
+import { db, safeRead } from "@server/db";
import { eq, and } from "drizzle-orm";
import config from "@server/lib/config";
@@ -66,15 +66,17 @@ export async function validateResourceSessionToken(
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token))
);
- const result = await db
- .select()
- .from(resourceSessions)
- .where(
- and(
- eq(resourceSessions.sessionId, sessionId),
- eq(resourceSessions.resourceId, resourceId)
+ const result = await safeRead((db) =>
+ db
+ .select()
+ .from(resourceSessions)
+ .where(
+ and(
+ eq(resourceSessions.sessionId, sessionId),
+ eq(resourceSessions.resourceId, resourceId)
+ )
)
- );
+ );
if (result.length < 1) {
return { resourceSession: null };
diff --git a/server/db/pg/index.ts b/server/db/pg/index.ts
index 97257502..f8c04ac9 100644
--- a/server/db/pg/index.ts
+++ b/server/db/pg/index.ts
@@ -1,5 +1,6 @@
export * from "./driver";
export * from "./logsDriver";
+export * from "./safeRead";
export * from "./schema/schema";
export * from "./schema/privateSchema";
export * from "./migrate";
diff --git a/server/db/pg/safeRead.ts b/server/db/pg/safeRead.ts
new file mode 100644
index 00000000..eac9ac31
--- /dev/null
+++ b/server/db/pg/safeRead.ts
@@ -0,0 +1,24 @@
+import { db, primaryDb } from "./driver";
+
+/**
+ * Runs a read query with replica fallback for Postgres.
+ * Executes the query against the replica first (when replicas exist).
+ * If the query throws or returns no data (null, undefined, or empty array),
+ * runs the same query against the primary.
+ */
+export async function safeRead(
+ query: (d: typeof db | typeof primaryDb) => Promise
+): Promise {
+ try {
+ const result = await query(db);
+ if (result === undefined || result === null) {
+ return query(primaryDb);
+ }
+ if (Array.isArray(result) && result.length === 0) {
+ return query(primaryDb);
+ }
+ return result;
+ } catch {
+ return query(primaryDb);
+ }
+}
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 7c252b8b..ae90020a 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -232,7 +232,11 @@ export const siteResources = pgTable("siteResources", {
aliasAddress: varchar("aliasAddress"),
tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"),
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
- disableIcmp: boolean("disableIcmp").notNull().default(false)
+ disableIcmp: boolean("disableIcmp").notNull().default(false),
+ authDaemonPort: integer("authDaemonPort").default(22123),
+ authDaemonMode: varchar("authDaemonMode", { length: 32 })
+ .$type<"site" | "remote">()
+ .default("site")
});
export const clientSiteResources = pgTable("clientSiteResources", {
@@ -372,7 +376,11 @@ export const roles = pgTable("roles", {
isAdmin: boolean("isAdmin"),
name: varchar("name").notNull(),
description: varchar("description"),
- requireDeviceApproval: boolean("requireDeviceApproval").default(false)
+ requireDeviceApproval: boolean("requireDeviceApproval").default(false),
+ sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands"
+ sshSudoCommands: text("sshSudoCommands").default("[]"),
+ sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
+ sshUnixGroups: text("sshUnixGroups").default("[]")
});
export const roleActions = pgTable("roleActions", {
@@ -1059,4 +1067,6 @@ export type SecurityKey = InferSelectModel;
export type WebauthnChallenge = InferSelectModel;
export type DeviceWebAuthCode = InferSelectModel;
export type RequestAuditLog = InferSelectModel;
-export type RoundTripMessageTracker = InferSelectModel;
+export type RoundTripMessageTracker = InferSelectModel<
+ typeof roundTripMessageTracker
+>;
diff --git a/server/db/sqlite/index.ts b/server/db/sqlite/index.ts
index 97257502..f8c04ac9 100644
--- a/server/db/sqlite/index.ts
+++ b/server/db/sqlite/index.ts
@@ -1,5 +1,6 @@
export * from "./driver";
export * from "./logsDriver";
+export * from "./safeRead";
export * from "./schema/schema";
export * from "./schema/privateSchema";
export * from "./migrate";
diff --git a/server/db/sqlite/safeRead.ts b/server/db/sqlite/safeRead.ts
new file mode 100644
index 00000000..6d3e9068
--- /dev/null
+++ b/server/db/sqlite/safeRead.ts
@@ -0,0 +1,11 @@
+import { db } from "./driver";
+
+/**
+ * Runs a read query. For SQLite there is no replica/primary distinction,
+ * so the query is executed once against the database.
+ */
+export async function safeRead(
+ query: (d: typeof db) => Promise
+): Promise {
+ return query(db);
+}
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 04d4338a..64866e67 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -257,7 +257,11 @@ export const siteResources = sqliteTable("siteResources", {
udpPortRangeString: text("udpPortRangeString").notNull().default("*"),
disableIcmp: integer("disableIcmp", { mode: "boolean" })
.notNull()
- .default(false)
+ .default(false),
+ authDaemonPort: integer("authDaemonPort").default(22123),
+ authDaemonMode: text("authDaemonMode")
+ .$type<"site" | "remote">()
+ .default("site")
});
export const clientSiteResources = sqliteTable("clientSiteResources", {
@@ -679,7 +683,13 @@ export const roles = sqliteTable("roles", {
description: text("description"),
requireDeviceApproval: integer("requireDeviceApproval", {
mode: "boolean"
- }).default(false)
+ }).default(false),
+ sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands"
+ sshSudoCommands: text("sshSudoCommands").default("[]"),
+ sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
+ true
+ ),
+ sshUnixGroups: text("sshUnixGroups").default("[]")
});
export const roleActions = sqliteTable("roleActions", {
diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts
index 64de9867..80c691c6 100644
--- a/server/lib/blueprints/clientResources.ts
+++ b/server/lib/blueprints/clientResources.ts
@@ -11,7 +11,7 @@ import {
userSiteResources
} from "@server/db";
import { sites } from "@server/db";
-import { eq, and, ne, inArray } from "drizzle-orm";
+import { eq, and, ne, inArray, or } from "drizzle-orm";
import { Config } from "./types";
import logger from "@server/logger";
import { getNextAvailableAliasAddress } from "../ip";
@@ -142,7 +142,10 @@ export async function updateClientResources(
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
- inArray(users.username, resourceData.users),
+ or(
+ inArray(users.username, resourceData.users),
+ inArray(users.email, resourceData.users)
+ ),
eq(userOrgs.orgId, orgId)
)
);
@@ -276,7 +279,10 @@ export async function updateClientResources(
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
- inArray(users.username, resourceData.users),
+ or(
+ inArray(users.username, resourceData.users),
+ inArray(users.email, resourceData.users)
+ ),
eq(userOrgs.orgId, orgId)
)
);
diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts
index 55a7712b..2696b68c 100644
--- a/server/lib/blueprints/proxyResources.ts
+++ b/server/lib/blueprints/proxyResources.ts
@@ -212,7 +212,10 @@ export async function updateProxyResources(
} else {
// Update existing resource
- const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage);
+ const isLicensed = await isLicensedOrSubscribed(
+ orgId,
+ tierMatrix.maintencePage
+ );
if (!isLicensed) {
resourceData.maintenance = undefined;
}
@@ -590,7 +593,10 @@ export async function updateProxyResources(
existingRule.action !== getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !==
- getRuleValue(rule.match.toUpperCase(), rule.value) ||
+ getRuleValue(
+ rule.match.toUpperCase(),
+ rule.value
+ ) ||
existingRule.priority !== intendedPriority
) {
validateRule(rule);
@@ -648,7 +654,10 @@ export async function updateProxyResources(
);
}
- const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage);
+ const isLicensed = await isLicensedOrSubscribed(
+ orgId,
+ tierMatrix.maintencePage
+ );
if (!isLicensed) {
resourceData.maintenance = undefined;
}
@@ -935,7 +944,12 @@ async function syncUserResources(
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
- .where(and(eq(users.username, username), eq(userOrgs.orgId, orgId)))
+ .where(
+ and(
+ or(eq(users.username, username), eq(users.email, username)),
+ eq(userOrgs.orgId, orgId)
+ )
+ )
.limit(1);
if (!user) {
diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts
index edf4b0c7..2239e4f9 100644
--- a/server/lib/blueprints/types.ts
+++ b/server/lib/blueprints/types.ts
@@ -69,7 +69,7 @@ export const AuthSchema = z.object({
.refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in sso-roles"
}),
- "sso-users": z.array(z.email()).optional().default([]),
+ "sso-users": z.array(z.string()).optional().default([]),
"whitelist-users": z.array(z.email()).optional().default([]),
"auto-login-idp": z.int().positive().optional()
});
@@ -335,7 +335,7 @@ export const ClientResourceSchema = z
.refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in roles"
}),
- users: z.array(z.email()).optional().default([]),
+ users: z.array(z.string()).optional().default([]),
machines: z.array(z.string()).optional().default([])
})
.refine(
diff --git a/server/middlewares/integration/verifyApiKeyRoleAccess.ts b/server/middlewares/integration/verifyApiKeyRoleAccess.ts
index ffe223a6..62bfb946 100644
--- a/server/middlewares/integration/verifyApiKeyRoleAccess.ts
+++ b/server/middlewares/integration/verifyApiKeyRoleAccess.ts
@@ -23,9 +23,14 @@ export async function verifyApiKeyRoleAccess(
);
}
- const { roleIds } = req.body;
- const allRoleIds =
- roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
+ let allRoleIds: number[] = [];
+ if (!isNaN(singleRoleId)) {
+ // If roleId is provided in URL params, query params, or body (single), use it exclusively
+ allRoleIds = [singleRoleId];
+ } else if (req.body?.roleIds) {
+ // Only use body.roleIds if no single roleId was provided
+ allRoleIds = req.body.roleIds;
+ }
if (allRoleIds.length === 0) {
return next();
diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts
index 91adf07c..8858ab53 100644
--- a/server/middlewares/verifyRoleAccess.ts
+++ b/server/middlewares/verifyRoleAccess.ts
@@ -23,8 +23,14 @@ export async function verifyRoleAccess(
);
}
- const roleIds = req.body?.roleIds;
- const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
+ let allRoleIds: number[] = [];
+ if (!isNaN(singleRoleId)) {
+ // If roleId is provided in URL params, query params, or body (single), use it exclusively
+ allRoleIds = [singleRoleId];
+ } else if (req.body?.roleIds) {
+ // Only use body.roleIds if no single roleId was provided
+ allRoleIds = req.body.roleIds;
+ }
if (allRoleIds.length === 0) {
return next();
diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts
index 7e68565e..4462a454 100644
--- a/server/private/lib/lock.ts
+++ b/server/private/lib/lock.ts
@@ -14,6 +14,9 @@
import { config } from "@server/lib/config";
import logger from "@server/logger";
import { redis } from "#private/lib/redis";
+import { v4 as uuidv4 } from "uuid";
+
+const instanceId = uuidv4();
export class LockManager {
/**
@@ -33,7 +36,7 @@ export class LockManager {
}
const lockValue = `${
- config.getRawConfig().gerbil.exit_node_name
+ instanceId
}:${Date.now()}`;
const redisKey = `lock:${lockKey}`;
@@ -52,7 +55,7 @@ export class LockManager {
if (result === "OK") {
logger.debug(
`Lock acquired: ${lockKey} by ${
- config.getRawConfig().gerbil.exit_node_name
+ instanceId
}`
);
return true;
@@ -63,14 +66,14 @@ export class LockManager {
if (
existingValue &&
existingValue.startsWith(
- `${config.getRawConfig().gerbil.exit_node_name}:`
+ `${instanceId}:`
)
) {
// Extend the lock TTL since it's the same worker
await redis.pexpire(redisKey, ttlMs);
logger.debug(
`Lock extended: ${lockKey} by ${
- config.getRawConfig().gerbil.exit_node_name
+ instanceId
}`
);
return true;
@@ -116,7 +119,7 @@ export class LockManager {
local key = KEYS[1]
local worker_prefix = ARGV[1]
local current_value = redis.call('GET', key)
-
+
if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then
return redis.call('DEL', key)
else
@@ -129,19 +132,19 @@ export class LockManager {
luaScript,
1,
redisKey,
- `${config.getRawConfig().gerbil.exit_node_name}:`
+ `${instanceId}:`
)) as number;
if (result === 1) {
logger.debug(
`Lock released: ${lockKey} by ${
- config.getRawConfig().gerbil.exit_node_name
+ instanceId
}`
);
} else {
logger.warn(
`Lock not released - not owned by worker: ${lockKey} by ${
- config.getRawConfig().gerbil.exit_node_name
+ instanceId
}`
);
}
@@ -198,7 +201,7 @@ export class LockManager {
const ownedByMe =
exists &&
value!.startsWith(
- `${config.getRawConfig().gerbil.exit_node_name}:`
+ `${instanceId}:`
);
const owner = exists ? value!.split(":")[0] : undefined;
@@ -233,7 +236,7 @@ export class LockManager {
local worker_prefix = ARGV[1]
local ttl = tonumber(ARGV[2])
local current_value = redis.call('GET', key)
-
+
if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then
return redis.call('PEXPIRE', key, ttl)
else
@@ -246,14 +249,14 @@ export class LockManager {
luaScript,
1,
redisKey,
- `${config.getRawConfig().gerbil.exit_node_name}:`,
+ `${instanceId}:`,
ttlMs.toString()
)) as number;
if (result === 1) {
logger.debug(
`Lock extended: ${lockKey} by ${
- config.getRawConfig().gerbil.exit_node_name
+ instanceId
} for ${ttlMs}ms`
);
return true;
@@ -356,7 +359,7 @@ export class LockManager {
(value) =>
value &&
value.startsWith(
- `${config.getRawConfig().gerbil.exit_node_name}:`
+ `${instanceId}:`
)
).length;
}
diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts
index 18bfe811..a19a1b65 100644
--- a/server/private/lib/readConfigFile.ts
+++ b/server/private/lib/readConfigFile.ts
@@ -72,15 +72,15 @@ export const privateConfigSchema = z.object({
db: z.int().nonnegative().optional().default(0)
})
)
+ .optional(),
+ tls: z
+ .object({
+ rejectUnauthorized: z
+ .boolean()
+ .optional()
+ .default(true)
+ })
.optional()
- // tls: z
- // .object({
- // reject_unauthorized: z
- // .boolean()
- // .optional()
- // .default(true)
- // })
- // .optional()
})
.optional(),
postgres_logs: z
diff --git a/server/private/lib/redis.ts b/server/private/lib/redis.ts
index 49cd4c61..69f563b4 100644
--- a/server/private/lib/redis.ts
+++ b/server/private/lib/redis.ts
@@ -108,11 +108,15 @@ class RedisManager {
port: redisConfig.port!,
password: redisConfig.password,
db: redisConfig.db
- // tls: {
- // rejectUnauthorized:
- // redisConfig.tls?.reject_unauthorized || false
- // }
};
+
+ // Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
+ if (redisConfig.tls) {
+ opts.tls = {
+ rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
+ };
+ }
+
return opts;
}
@@ -130,11 +134,15 @@ class RedisManager {
port: replica.port!,
password: replica.password,
db: replica.db || redisConfig.db
- // tls: {
- // rejectUnauthorized:
- // replica.tls?.reject_unauthorized || false
- // }
};
+
+ // Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
+ if (redisConfig.tls) {
+ opts.tls = {
+ rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
+ };
+ }
+
return opts;
}
diff --git a/server/private/lib/sshCA.ts b/server/private/lib/sshCA.ts
index 145dac61..6c9d1209 100644
--- a/server/private/lib/sshCA.ts
+++ b/server/private/lib/sshCA.ts
@@ -61,7 +61,10 @@ function encodeUInt64(value: bigint): Buffer {
* Decode a string from SSH wire format at the given offset
* Returns the string buffer and the new offset
*/
-function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } {
+function decodeString(
+ data: Buffer,
+ offset: number
+): { value: Buffer; newOffset: number } {
const len = data.readUInt32BE(offset);
const value = data.subarray(offset + 4, offset + 4 + len);
return { value, newOffset: offset + 4 + len };
@@ -91,7 +94,9 @@ function parseOpenSSHPublicKey(pubKeyLine: string): {
// Verify the key type in the blob matches
const { value: blobKeyType } = decodeString(keyData, 0);
if (blobKeyType.toString("utf8") !== keyType) {
- throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`);
+ throw new Error(
+ `Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`
+ );
}
return { keyType, keyData, comment };
@@ -238,7 +243,7 @@ export interface SignedCertificate {
* @param comment - Optional comment for the CA public key
* @returns CA key pair and configuration info
*/
-export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
+export function generateCA(comment: string = "pangolin-ssh-ca"): CAKeyPair {
// Generate Ed25519 key pair
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" },
@@ -269,7 +274,7 @@ export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
/**
* Get and decrypt the SSH CA keys for an organization.
- *
+ *
* @param orgId - Organization ID
* @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config)
* @returns CA key pair or null if not found
@@ -307,7 +312,10 @@ export async function getOrgCAKeys(
key: privateKeyPem,
format: "pem"
});
- const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string;
+ const publicKeyPem = pubKeyObj.export({
+ type: "spki",
+ format: "pem"
+ }) as string;
return {
privateKeyPem,
@@ -365,8 +373,8 @@ export function signPublicKey(
const serial = options.serial ?? BigInt(Date.now());
const certType = options.certType ?? 1; // 1 = user cert
const now = BigInt(Math.floor(Date.now() / 1000));
- const validAfter = options.validAfter ?? (now - 60n); // 1 minute ago
- const validBefore = options.validBefore ?? (now + 86400n * 365n); // 1 year from now
+ const validAfter = options.validAfter ?? now - 60n; // 1 minute ago
+ const validBefore = options.validBefore ?? now + 86400n * 365n; // 1 year from now
// Default extensions for user certificates
const defaultExtensions = [
@@ -422,10 +430,7 @@ export function signPublicKey(
]);
// Build complete certificate
- const certificate = Buffer.concat([
- certBody,
- encodeString(signatureBlob)
- ]);
+ const certificate = Buffer.concat([certBody, encodeString(signatureBlob)]);
// Format as OpenSSH certificate line
const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`;
diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts
index 3e4b8a4a..9536a87f 100644
--- a/server/private/routers/billing/featureLifecycle.ts
+++ b/server/private/routers/billing/featureLifecycle.ts
@@ -25,7 +25,8 @@ import {
loginPageOrg,
orgs,
resources,
- roles
+ roles,
+ siteResources
} from "@server/db";
import { eq } from "drizzle-orm";
@@ -286,6 +287,10 @@ async function disableFeature(
await disableAutoProvisioning(orgId);
break;
+ case TierFeature.SshPam:
+ await disableSshPam(orgId);
+ break;
+
default:
logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping`
@@ -315,6 +320,12 @@ async function disableDeviceApprovals(orgId: string): Promise {
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
}
+async function disableSshPam(orgId: string): Promise {
+ logger.info(
+ `Disabled SSH PAM options on all roles and site resources for org ${orgId}`
+ );
+}
+
async function disableLoginPageBranding(orgId: string): Promise {
const [existingBranding] = await db
.select()
diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts
index 17132c44..a1352342 100644
--- a/server/private/routers/external.ts
+++ b/server/private/routers/external.ts
@@ -514,7 +514,7 @@ authenticated.post(
verifyValidSubscription(tierMatrix.sshPam),
verifyOrgAccess,
verifyLimits,
- // verifyUserHasAction(ActionsEnum.signSshKey),
+ verifyUserHasAction(ActionsEnum.signSshKey),
logActionAudit(ActionsEnum.signSshKey),
ssh.signSshKey
);
diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts
index 9ffce8c1..fbdee72d 100644
--- a/server/private/routers/ssh/signSshKey.ts
+++ b/server/private/routers/ssh/signSshKey.ts
@@ -13,7 +13,17 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { db, newts, orgs, roundTripMessageTracker, siteResources, sites, userOrgs } from "@server/db";
+import {
+ db,
+ newts,
+ roles,
+ roundTripMessageTracker,
+ siteResources,
+ sites,
+ userOrgs
+} from "@server/db";
+import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -135,11 +145,26 @@ export async function signSshKey(
);
}
+ const isLicensed = await isLicensedOrSubscribed(
+ orgId,
+ tierMatrix.sshPam
+ );
+ if (!isLicensed) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "SSH key signing requires a paid plan"
+ )
+ );
+ }
+
let usernameToUse;
if (!userOrg.pamUsername) {
if (req.user?.email) {
// Extract username from email (first part before @)
- usernameToUse = req.user?.email.split("@")[0];
+ usernameToUse = req.user?.email
+ .split("@")[0]
+ .replace(/[^a-zA-Z0-9_-]/g, "");
if (!usernameToUse) {
return next(
createHttpError(
@@ -301,6 +326,29 @@ export async function signSshKey(
);
}
+ const [roleRow] = await db
+ .select()
+ .from(roles)
+ .where(eq(roles.roleId, roleId))
+ .limit(1);
+
+ let parsedSudoCommands: string[] = [];
+ let parsedGroups: string[] = [];
+ try {
+ parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
+ if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = [];
+ } catch {
+ parsedSudoCommands = [];
+ }
+ try {
+ parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
+ if (!Array.isArray(parsedGroups)) parsedGroups = [];
+ } catch {
+ parsedGroups = [];
+ }
+ const homedir = roleRow?.sshCreateHomeDir ?? null;
+ const sudoMode = roleRow?.sshSudoMode ?? "none";
+
// get the site
const [newt] = await db
.select()
@@ -334,7 +382,7 @@ export async function signSshKey(
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
- sentAt: Math.floor(Date.now() / 1000),
+ sentAt: Math.floor(Date.now() / 1000)
})
.returning();
@@ -352,14 +400,17 @@ export async function signSshKey(
data: {
messageId: message.messageId,
orgId: orgId,
- agentPort: 22123,
+ agentPort: resource.authDaemonPort ?? 22123,
+ externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
- sudo: true, // we are hardcoding these for now but should make configurable from the role or something
- homedir: true
+ sudoMode: sudoMode,
+ sudoCommands: parsedSudoCommands,
+ homedir: homedir,
+ groups: parsedGroups
}
}
});
diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts
index 12d0a199..8ef01a2f 100644
--- a/server/routers/client/updateClient.ts
+++ b/server/routers/client/updateClient.ts
@@ -6,7 +6,7 @@ import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
-import { eq, and } from "drizzle-orm";
+import { eq, and, ne } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@@ -93,7 +93,8 @@ export async function updateClient(
.where(
and(
eq(clients.niceId, niceId),
- eq(clients.orgId, clients.orgId)
+ eq(clients.orgId, clients.orgId),
+ ne(clients.clientId, clientId)
)
)
.limit(1);
diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts
index 59aa86d2..1a5d8799 100644
--- a/server/routers/org/createOrg.ts
+++ b/server/routers/org/createOrg.ts
@@ -181,7 +181,10 @@ export async function createOrg(
}
if (build == "saas" && billingOrgIdForNewOrg) {
- const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS);
+ const usage = await usageService.getUsage(
+ billingOrgIdForNewOrg,
+ FeatureId.ORGINIZATIONS
+ );
if (!usage) {
return next(
createHttpError(
@@ -218,11 +221,6 @@ export async function createOrg(
.from(domains)
.where(eq(domains.configManaged, true));
- // Generate SSH CA keys for the org
- // const ca = generateCA(`${orgId}-ca`);
- // const encryptionKey = config.getRawConfig().server.secret!;
- // const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
-
const saasBillingFields =
build === "saas" && req.user && isFirstOrg !== null
? isFirstOrg
@@ -233,6 +231,19 @@ export async function createOrg(
}
: {};
+ const encryptionKey = config.getRawConfig().server.secret;
+ let sshCaFields: {
+ sshCaPrivateKey?: string;
+ sshCaPublicKey?: string;
+ } = {};
+ if (encryptionKey) {
+ const ca = generateCA(`pangolin-ssh-ca-${orgId}`);
+ sshCaFields = {
+ sshCaPrivateKey: encrypt(ca.privateKeyPem, encryptionKey),
+ sshCaPublicKey: ca.publicKeyOpenSSH
+ };
+ }
+
const newOrg = await trx
.insert(orgs)
.values({
@@ -241,8 +252,7 @@ export async function createOrg(
subnet,
utilitySubnet,
createdAt: new Date().toISOString(),
- // sshCaPrivateKey: encryptedCaPrivateKey,
- // sshCaPublicKey: ca.publicKeyOpenSSH,
+ ...sshCaFields,
...saasBillingFields
})
.returning();
@@ -262,7 +272,8 @@ export async function createOrg(
orgId: newOrg[0].orgId,
isAdmin: true,
name: "Admin",
- description: "Admin role with the most permissions"
+ description: "Admin role with the most permissions",
+ sshSudoMode: "full"
})
.returning({ roleId: roles.roleId });
diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts
index 4f35739b..4a3e65fa 100644
--- a/server/routers/resource/updateResource.ts
+++ b/server/routers/resource/updateResource.ts
@@ -9,7 +9,7 @@ import {
Resource,
resources
} from "@server/db";
-import { eq, and } from "drizzle-orm";
+import { eq, and, ne } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -33,7 +33,15 @@ const updateResourceParamsSchema = z.strictObject({
const updateHttpResourceBodySchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
- niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
+ niceId: z
+ .string()
+ .min(1)
+ .max(255)
+ .regex(
+ /^[a-zA-Z0-9-]+$/,
+ "niceId can only contain letters, numbers, and dashes"
+ )
+ .optional(),
subdomain: subdomainSchema.nullable().optional(),
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
@@ -248,14 +256,13 @@ async function updateHttpResource(
.where(
and(
eq(resources.niceId, updateData.niceId),
- eq(resources.orgId, resource.orgId)
+ eq(resources.orgId, resource.orgId),
+ ne(resources.resourceId, resource.resourceId) // exclude the current resource from the search
)
- );
+ )
+ .limit(1);
- if (
- existingResource &&
- existingResource.resourceId !== resource.resourceId
- ) {
+ if (existingResource) {
return next(
createHttpError(
HttpCode.CONFLICT,
@@ -343,7 +350,10 @@ async function updateHttpResource(
headers = null;
}
- const isLicensed = await isLicensedOrSubscribed(resource.orgId, tierMatrix.maintencePage);
+ const isLicensed = await isLicensedOrSubscribed(
+ resource.orgId,
+ tierMatrix.maintencePage
+ );
if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined;
diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts
index edb8f1bd..e732b405 100644
--- a/server/routers/role/createRole.ts
+++ b/server/routers/role/createRole.ts
@@ -18,10 +18,17 @@ const createRoleParamsSchema = z.strictObject({
orgId: z.string()
});
+const sshSudoModeSchema = z.enum(["none", "full", "commands"]);
+
const createRoleSchema = z.strictObject({
name: z.string().min(1).max(255),
description: z.string().optional(),
- requireDeviceApproval: z.boolean().optional()
+ requireDeviceApproval: z.boolean().optional(),
+ allowSsh: z.boolean().optional(),
+ sshSudoMode: sshSudoModeSchema.optional(),
+ sshSudoCommands: z.array(z.string()).optional(),
+ sshCreateHomeDir: z.boolean().optional(),
+ sshUnixGroups: z.array(z.string()).optional()
});
export const defaultRoleAllowedActions: ActionsEnum[] = [
@@ -101,24 +108,40 @@ export async function createRole(
);
}
- const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
- if (!isLicensed) {
+ const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
+ if (!isLicensedDeviceApprovals) {
roleData.requireDeviceApproval = undefined;
}
+ const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam);
+ const roleInsertValues: Record = {
+ name: roleData.name,
+ orgId
+ };
+ if (roleData.description !== undefined) roleInsertValues.description = roleData.description;
+ if (roleData.requireDeviceApproval !== undefined) roleInsertValues.requireDeviceApproval = roleData.requireDeviceApproval;
+ if (isLicensedSshPam) {
+ if (roleData.sshSudoMode !== undefined) roleInsertValues.sshSudoMode = roleData.sshSudoMode;
+ if (roleData.sshSudoCommands !== undefined) roleInsertValues.sshSudoCommands = JSON.stringify(roleData.sshSudoCommands);
+ if (roleData.sshCreateHomeDir !== undefined) roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir;
+ if (roleData.sshUnixGroups !== undefined) roleInsertValues.sshUnixGroups = JSON.stringify(roleData.sshUnixGroups);
+ }
+
await db.transaction(async (trx) => {
const newRole = await trx
.insert(roles)
- .values({
- ...roleData,
- orgId
- })
+ .values(roleInsertValues as typeof roles.$inferInsert)
.returning();
+ const actionsToInsert = [...defaultRoleAllowedActions];
+ if (roleData.allowSsh) {
+ actionsToInsert.push(ActionsEnum.signSshKey);
+ }
+
await trx
.insert(roleActions)
.values(
- defaultRoleAllowedActions.map((action) => ({
+ actionsToInsert.map((action) => ({
roleId: newRole[0].roleId,
actionId: action,
orgId
diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts
index ec7f3b4b..d4cb580f 100644
--- a/server/routers/role/listRoles.ts
+++ b/server/routers/role/listRoles.ts
@@ -1,9 +1,10 @@
-import { db, orgs, roles } from "@server/db";
+import { db, orgs, roleActions, roles } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
-import { eq, sql } from "drizzle-orm";
+import { and, eq, inArray, sql } from "drizzle-orm";
+import { ActionsEnum } from "@server/auth/actions";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -37,7 +38,11 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
name: roles.name,
description: roles.description,
orgName: orgs.name,
- requireDeviceApproval: roles.requireDeviceApproval
+ requireDeviceApproval: roles.requireDeviceApproval,
+ sshSudoMode: roles.sshSudoMode,
+ sshSudoCommands: roles.sshSudoCommands,
+ sshCreateHomeDir: roles.sshCreateHomeDir,
+ sshUnixGroups: roles.sshUnixGroups
})
.from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
@@ -106,9 +111,28 @@ export async function listRoles(
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
+ let rolesWithAllowSsh = rolesList;
+ if (rolesList.length > 0) {
+ const roleIds = rolesList.map((r) => r.roleId);
+ const signSshKeyRows = await db
+ .select({ roleId: roleActions.roleId })
+ .from(roleActions)
+ .where(
+ and(
+ inArray(roleActions.roleId, roleIds),
+ eq(roleActions.actionId, ActionsEnum.signSshKey)
+ )
+ );
+ const roleIdsWithSsh = new Set(signSshKeyRows.map((r) => r.roleId));
+ rolesWithAllowSsh = rolesList.map((r) => ({
+ ...r,
+ allowSsh: roleIdsWithSsh.has(r.roleId)
+ }));
+ }
+
return response(res, {
data: {
- roles: rolesList,
+ roles: rolesWithAllowSsh,
pagination: {
total: totalCount,
limit,
diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts
index 51a33e32..7400e582 100644
--- a/server/routers/role/updateRole.ts
+++ b/server/routers/role/updateRole.ts
@@ -1,8 +1,9 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, type Role } from "@server/db";
-import { roles } from "@server/db";
-import { eq } from "drizzle-orm";
+import { roleActions, roles } from "@server/db";
+import { and, eq } from "drizzle-orm";
+import { ActionsEnum } from "@server/auth/actions";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -16,11 +17,18 @@ const updateRoleParamsSchema = z.strictObject({
roleId: z.string().transform(Number).pipe(z.int().positive())
});
+const sshSudoModeSchema = z.enum(["none", "full", "commands"]);
+
const updateRoleBodySchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
- requireDeviceApproval: z.boolean().optional()
+ requireDeviceApproval: z.boolean().optional(),
+ allowSsh: z.boolean().optional(),
+ sshSudoMode: sshSudoModeSchema.optional(),
+ sshSudoCommands: z.array(z.string()).optional(),
+ sshCreateHomeDir: z.boolean().optional(),
+ sshUnixGroups: z.array(z.string()).optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
@@ -75,7 +83,9 @@ export async function updateRole(
}
const { roleId } = parsedParams.data;
- const updateData = parsedBody.data;
+ const body = parsedBody.data;
+ const { allowSsh, ...restBody } = body;
+ const updateData: Record = { ...restBody };
const role = await db
.select()
@@ -92,16 +102,14 @@ export async function updateRole(
);
}
- if (role[0].isAdmin) {
- return next(
- createHttpError(
- HttpCode.FORBIDDEN,
- `Cannot update a Admin role`
- )
- );
+ const orgId = role[0].orgId;
+ const isAdminRole = role[0].isAdmin;
+
+ if (isAdminRole) {
+ delete updateData.name;
+ delete updateData.description;
}
- const orgId = role[0].orgId;
if (!orgId) {
return next(
createHttpError(
@@ -111,18 +119,70 @@ export async function updateRole(
);
}
- const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
- if (!isLicensed) {
+ const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
+ if (!isLicensedDeviceApprovals) {
updateData.requireDeviceApproval = undefined;
}
- const updatedRole = await db
- .update(roles)
- .set(updateData)
- .where(eq(roles.roleId, roleId))
- .returning();
+ const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam);
+ if (!isLicensedSshPam) {
+ delete updateData.sshSudoMode;
+ delete updateData.sshSudoCommands;
+ delete updateData.sshCreateHomeDir;
+ delete updateData.sshUnixGroups;
+ } else {
+ if (Array.isArray(updateData.sshSudoCommands)) {
+ updateData.sshSudoCommands = JSON.stringify(updateData.sshSudoCommands);
+ }
+ if (Array.isArray(updateData.sshUnixGroups)) {
+ updateData.sshUnixGroups = JSON.stringify(updateData.sshUnixGroups);
+ }
+ }
- if (updatedRole.length === 0) {
+ const updatedRole = await db.transaction(async (trx) => {
+ const result = await trx
+ .update(roles)
+ .set(updateData as typeof roles.$inferInsert)
+ .where(eq(roles.roleId, roleId))
+ .returning();
+
+ if (result.length === 0) {
+ return null;
+ }
+
+ if (allowSsh === true) {
+ const existing = await trx
+ .select()
+ .from(roleActions)
+ .where(
+ and(
+ eq(roleActions.roleId, roleId),
+ eq(roleActions.actionId, ActionsEnum.signSshKey)
+ )
+ )
+ .limit(1);
+ if (existing.length === 0) {
+ await trx.insert(roleActions).values({
+ roleId,
+ actionId: ActionsEnum.signSshKey,
+ orgId: orgId!
+ });
+ }
+ } else if (allowSsh === false) {
+ await trx
+ .delete(roleActions)
+ .where(
+ and(
+ eq(roleActions.roleId, roleId),
+ eq(roleActions.actionId, ActionsEnum.signSshKey)
+ )
+ );
+ }
+
+ return result[0];
+ });
+
+ if (!updatedRole) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -132,7 +192,7 @@ export async function updateRole(
}
return response(res, {
- data: updatedRole[0],
+ data: updatedRole,
success: true,
error: false,
message: "Role updated successfully",
diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts
index 44764362..ca0f7678 100644
--- a/server/routers/site/updateSite.ts
+++ b/server/routers/site/updateSite.ts
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { sites } from "@server/db";
-import { eq, and } from "drizzle-orm";
+import { eq, and, ne } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -19,8 +19,8 @@ const updateSiteBodySchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
niceId: z.string().min(1).max(255).optional(),
- dockerSocketEnabled: z.boolean().optional(),
- remoteSubnets: z.string().optional()
+ dockerSocketEnabled: z.boolean().optional()
+ // remoteSubnets: z.string().optional()
// subdomain: z
// .string()
// .min(1)
@@ -86,18 +86,19 @@ export async function updateSite(
// if niceId is provided, check if it's already in use by another site
if (updateData.niceId) {
- const existingSite = await db
+ const [existingSite] = await db
.select()
.from(sites)
.where(
and(
eq(sites.niceId, updateData.niceId),
- eq(sites.orgId, sites.orgId)
+ eq(sites.orgId, sites.orgId),
+ ne(sites.siteId, siteId)
)
)
.limit(1);
- if (existingSite.length > 0 && existingSite[0].siteId !== siteId) {
+ if (existingSite) {
return next(
createHttpError(
HttpCode.CONFLICT,
@@ -107,22 +108,22 @@ export async function updateSite(
}
}
- // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
- if (updateData.remoteSubnets) {
- const subnets = updateData.remoteSubnets
- .split(",")
- .map((s) => s.trim());
- for (const subnet of subnets) {
- if (!isValidCIDR(subnet)) {
- return next(
- createHttpError(
- HttpCode.BAD_REQUEST,
- `Invalid CIDR format: ${subnet}`
- )
- );
- }
- }
- }
+ // // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
+ // if (updateData.remoteSubnets) {
+ // const subnets = updateData.remoteSubnets
+ // .split(",")
+ // .map((s) => s.trim());
+ // for (const subnet of subnets) {
+ // if (!isValidCIDR(subnet)) {
+ // return next(
+ // createHttpError(
+ // HttpCode.BAD_REQUEST,
+ // `Invalid CIDR format: ${subnet}`
+ // )
+ // );
+ // }
+ // }
+ // }
const updatedSite = await db
.update(sites)
diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts
index 48c298d3..bbdc3638 100644
--- a/server/routers/siteResource/createSiteResource.ts
+++ b/server/routers/siteResource/createSiteResource.ts
@@ -16,6 +16,8 @@ import {
isIpInCidr,
portRangeStringSchema
} from "@server/lib/ip";
+import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger";
@@ -53,7 +55,9 @@ const createSiteResourceSchema = z
clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
- disableIcmp: z.boolean().optional()
+ disableIcmp: z.boolean().optional(),
+ authDaemonPort: z.int().positive().optional(),
+ authDaemonMode: z.enum(["site", "remote"]).optional()
})
.strict()
.refine(
@@ -168,7 +172,9 @@ export async function createSiteResource(
clientIds,
tcpPortRangeString,
udpPortRangeString,
- disableIcmp
+ disableIcmp,
+ authDaemonPort,
+ authDaemonMode
} = parsedBody.data;
// Verify the site exists and belongs to the org
@@ -267,6 +273,11 @@ export async function createSiteResource(
}
}
+ const isLicensedSshPam = await isLicensedOrSubscribed(
+ orgId,
+ tierMatrix.sshPam
+ );
+
const niceId = await getUniqueSiteResourceName(orgId);
let aliasAddress: string | null = null;
if (mode == "host") {
@@ -277,25 +288,29 @@ export async function createSiteResource(
let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
// Create the site resource
+ const insertValues: typeof siteResources.$inferInsert = {
+ siteId,
+ niceId,
+ orgId,
+ name,
+ mode: mode as "host" | "cidr",
+ destination,
+ enabled,
+ alias,
+ aliasAddress,
+ tcpPortRangeString,
+ udpPortRangeString,
+ disableIcmp
+ };
+ if (isLicensedSshPam) {
+ if (authDaemonPort !== undefined)
+ insertValues.authDaemonPort = authDaemonPort;
+ if (authDaemonMode !== undefined)
+ insertValues.authDaemonMode = authDaemonMode;
+ }
[newSiteResource] = await trx
.insert(siteResources)
- .values({
- siteId,
- niceId,
- orgId,
- name,
- mode: mode as "host" | "cidr",
- // protocol: mode === "port" ? protocol : null,
- // proxyPort: mode === "port" ? proxyPort : null,
- // destinationPort: mode === "port" ? destinationPort : null,
- destination,
- enabled,
- alias,
- aliasAddress,
- tcpPortRangeString,
- udpPortRangeString,
- disableIcmp
- })
+ .values(insertValues)
.returning();
const siteResourceId = newSiteResource.siteResourceId;
diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
index ead1fc8a..5aec53c7 100644
--- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts
+++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
@@ -78,6 +78,8 @@ function querySiteResourcesBase() {
tcpPortRangeString: siteResources.tcpPortRangeString,
udpPortRangeString: siteResources.udpPortRangeString,
disableIcmp: siteResources.disableIcmp,
+ authDaemonMode: siteResources.authDaemonMode,
+ authDaemonPort: siteResources.authDaemonPort,
siteName: sites.name,
siteNiceId: sites.niceId,
siteAddress: sites.address
diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts
index 4c19bea1..242b9226 100644
--- a/server/routers/siteResource/updateSiteResource.ts
+++ b/server/routers/siteResource/updateSiteResource.ts
@@ -32,6 +32,8 @@ import {
getClientSiteResourceAccess,
rebuildClientAssociationsFromSiteResource
} from "@server/lib/rebuildClientAssociations";
+import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
const updateSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -61,7 +63,9 @@ const updateSiteResourceSchema = z
clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
- disableIcmp: z.boolean().optional()
+ disableIcmp: z.boolean().optional(),
+ authDaemonPort: z.int().positive().nullish(),
+ authDaemonMode: z.enum(["site", "remote"]).optional()
})
.strict()
.refine(
@@ -172,7 +176,9 @@ export async function updateSiteResource(
clientIds,
tcpPortRangeString,
udpPortRangeString,
- disableIcmp
+ disableIcmp,
+ authDaemonPort,
+ authDaemonMode
} = parsedBody.data;
const [site] = await db
@@ -198,6 +204,11 @@ export async function updateSiteResource(
);
}
+ const isLicensedSshPam = await isLicensedOrSubscribed(
+ existingSiteResource.orgId,
+ tierMatrix.sshPam
+ );
+
const [org] = await db
.select()
.from(orgs)
@@ -308,6 +319,18 @@ export async function updateSiteResource(
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
+ const sshPamSet =
+ isLicensedSshPam &&
+ (authDaemonPort !== undefined || authDaemonMode !== undefined)
+ ? {
+ ...(authDaemonPort !== undefined && {
+ authDaemonPort
+ }),
+ ...(authDaemonMode !== undefined && {
+ authDaemonMode
+ })
+ }
+ : {};
[updatedSiteResource] = await trx
.update(siteResources)
.set({
@@ -319,7 +342,8 @@ export async function updateSiteResource(
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
- disableIcmp: disableIcmp
+ disableIcmp: disableIcmp,
+ ...sshPamSet
})
.where(
and(
@@ -397,6 +421,18 @@ export async function updateSiteResource(
);
} else {
// Update the site resource
+ const sshPamSet =
+ isLicensedSshPam &&
+ (authDaemonPort !== undefined || authDaemonMode !== undefined)
+ ? {
+ ...(authDaemonPort !== undefined && {
+ authDaemonPort
+ }),
+ ...(authDaemonMode !== undefined && {
+ authDaemonMode
+ })
+ }
+ : {};
[updatedSiteResource] = await trx
.update(siteResources)
.set({
@@ -408,7 +444,8 @@ export async function updateSiteResource(
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
- disableIcmp: disableIcmp
+ disableIcmp: disableIcmp,
+ ...sshPamSet
})
.where(
and(eq(siteResources.siteResourceId, siteResourceId))
diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts
index aa2e040d..be376817 100644
--- a/server/setup/copyInConfig.ts
+++ b/server/setup/copyInConfig.ts
@@ -2,9 +2,13 @@ import { db, dnsRecords } from "@server/db";
import { domains, exitNodes, orgDomains, orgs, resources } from "@server/db";
import config from "@server/lib/config";
import { eq, ne } from "drizzle-orm";
-import logger from "@server/logger";
+import { build } from "@server/build";
export async function copyInConfig() {
+ if (build == "saas") {
+ return;
+ }
+
const endpoint = config.getRawConfig().gerbil.base_endpoint;
const listenPort = config.getRawConfig().gerbil.start_port;
diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts
index fd28644c..8d27435a 100644
--- a/server/setup/migrationsPg.ts
+++ b/server/setup/migrationsPg.ts
@@ -19,6 +19,7 @@ import m11 from "./scriptsPg/1.14.0";
import m12 from "./scriptsPg/1.15.0";
import m13 from "./scriptsPg/1.15.3";
import m14 from "./scriptsPg/1.15.4";
+import { build } from "@server/build";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -53,6 +54,10 @@ async function run() {
}
export async function runMigrations() {
+ if (build == "saas") {
+ console.log("Running in SaaS mode, skipping migrations...");
+ return;
+ }
if (process.env.DISABLE_MIGRATIONS) {
console.log("Migrations are disabled. Skipping...");
return;
diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts
index 39c133bf..17bc7f19 100644
--- a/server/setup/migrationsSqlite.ts
+++ b/server/setup/migrationsSqlite.ts
@@ -37,6 +37,7 @@ import m32 from "./scriptsSqlite/1.14.0";
import m33 from "./scriptsSqlite/1.15.0";
import m34 from "./scriptsSqlite/1.15.3";
import m35 from "./scriptsSqlite/1.15.4";
+import { build } from "@server/build";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -105,6 +106,10 @@ function backupDb() {
}
export async function runMigrations() {
+ if (build == "saas") {
+ console.log("Running in SaaS mode, skipping migrations...");
+ return;
+ }
if (process.env.DISABLE_MIGRATIONS) {
console.log("Migrations are disabled. Skipping...");
return;
diff --git a/server/setup/scriptsSqlite/1.16.0.ts b/server/setup/scriptsSqlite/1.16.0.ts
new file mode 100644
index 00000000..45936733
--- /dev/null
+++ b/server/setup/scriptsSqlite/1.16.0.ts
@@ -0,0 +1,29 @@
+import { __DIRNAME, APP_PATH } from "@server/lib/consts";
+import Database from "better-sqlite3";
+import path from "path";
+
+const version = "1.16.0";
+
+export default async function migration() {
+ console.log(`Running setup script ${version}...`);
+
+ const location = path.join(APP_PATH, "db", "db.sqlite");
+ const db = new Database(location);
+
+ // set all admin role sudo to "full"; all other roles to "none"
+ // all roles set hoemdir to true
+
+ // generate ca certs for all orgs?
+ // set authDaemonMode to "site" for all site-resources
+
+ try {
+ db.transaction(() => {})();
+
+ console.log(`Migrated database`);
+ } catch (e) {
+ console.log("Failed to migrate db:", e);
+ throw e;
+ }
+
+ console.log(`${version} migration complete`);
+}
diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx
index bad8bda2..24fa7480 100644
--- a/src/app/[orgId]/settings/(private)/billing/page.tsx
+++ b/src/app/[orgId]/settings/(private)/billing/page.tsx
@@ -445,6 +445,54 @@ export default function BillingPage() {
const currentPlanId = getCurrentPlanId();
+ // Check if subscription is in a problematic state that requires attention
+ const hasProblematicSubscription = (): boolean => {
+ if (!tierSubscription?.subscription) return false;
+ const status = tierSubscription.subscription.status;
+ return (
+ status === "past_due" ||
+ status === "unpaid" ||
+ status === "incomplete" ||
+ status === "incomplete_expired"
+ );
+ };
+
+ const isProblematicState = hasProblematicSubscription();
+
+ // Get user-friendly subscription status message
+ const getSubscriptionStatusMessage = (): { title: string; description: string } | null => {
+ if (!tierSubscription?.subscription || !isProblematicState) return null;
+
+ const status = tierSubscription.subscription.status;
+
+ switch (status) {
+ case "past_due":
+ return {
+ title: t("billingPastDueTitle") || "Payment Past Due",
+ description: t("billingPastDueDescription") || "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
+ };
+ case "unpaid":
+ return {
+ title: t("billingUnpaidTitle") || "Subscription Unpaid",
+ description: t("billingUnpaidDescription") || "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
+ };
+ case "incomplete":
+ return {
+ title: t("billingIncompleteTitle") || "Payment Incomplete",
+ description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription."
+ };
+ case "incomplete_expired":
+ return {
+ title: t("billingIncompleteExpiredTitle") || "Payment Expired",
+ description: t("billingIncompleteExpiredDescription") || "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
+ };
+ default:
+ return null;
+ }
+ };
+
+ const statusMessage = getSubscriptionStatusMessage();
+
// Get button label and action for each plan
const getPlanAction = (plan: PlanOption) => {
if (plan.id === "enterprise") {
@@ -458,7 +506,7 @@ export default function BillingPage() {
if (plan.id === currentPlanId) {
// If it's the basic plan (basic with no subscription), show as current but disabled
- if (plan.id === "basic" && !hasSubscription) {
+ if (plan.id === "basic" && !hasSubscription && !isProblematicState) {
return {
label: "Current Plan",
action: () => {},
@@ -466,8 +514,17 @@ export default function BillingPage() {
disabled: true
};
}
+ // If on free tier but has a problematic subscription, allow them to manage it
+ if (plan.id === "basic" && isProblematicState) {
+ return {
+ label: "Manage Subscription",
+ action: handleModifySubscription,
+ variant: "default" as const,
+ disabled: false
+ };
+ }
return {
- label: "Modify Current Plan",
+ label: "Manage Current Plan",
action: handleModifySubscription,
variant: "default" as const,
disabled: false
@@ -503,7 +560,7 @@ export default function BillingPage() {
}
},
variant: "outline" as const,
- disabled: false
+ disabled: isProblematicState
};
}
@@ -522,7 +579,7 @@ export default function BillingPage() {
}
},
variant: "outline" as const,
- disabled: false
+ disabled: isProblematicState
};
};
@@ -648,6 +705,26 @@ export default function BillingPage() {
return (
+ {/* Subscription Status Alert */}
+ {isProblematicState && statusMessage && (
+
+
+
+ {statusMessage.title}
+
+
+ {statusMessage.description}
+ {" "}
+
+
+
+ )}
+
{/* Your Plan Section */}
@@ -692,22 +769,50 @@ export default function BillingPage() {