mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-21 20:36:37 +00:00
Compare commits
146 Commits
dependabot
...
1.15.4-s.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63694032e8 | ||
|
|
b77aaedb58 | ||
|
|
a316d0301f | ||
|
|
dcd499720e | ||
|
|
e18fe21eca | ||
|
|
2970b51fb8 | ||
|
|
b9236ff52e | ||
|
|
38eb0ec7ed | ||
|
|
ecba4a0b80 | ||
|
|
e6da18c952 | ||
|
|
12941ac5ae | ||
|
|
11085bda63 | ||
|
|
c03211cc53 | ||
|
|
2867459600 | ||
|
|
32b24db9bf | ||
|
|
660bf9ff87 | ||
|
|
78c4ddebba | ||
|
|
f2dfadb37b | ||
|
|
3f2bdf081f | ||
|
|
d6ba34aeea | ||
|
|
6442eb12fb | ||
|
|
01c15afa74 | ||
|
|
4e88f1f38a | ||
|
|
13ab505f4d | ||
|
|
7d112aab27 | ||
|
|
eedf57af89 | ||
|
|
7a01a4e090 | ||
|
|
874794c996 | ||
|
|
5e37c4e85f | ||
|
|
4e7eac368f | ||
|
|
756f3f32ca | ||
|
|
362981ad19 | ||
|
|
fa4f7e4ac2 | ||
|
|
c6bca4e2ab | ||
|
|
e28b361e05 | ||
|
|
a18691011b | ||
|
|
c4a6403cba | ||
|
|
1851bf941a | ||
|
|
b7ab3c2e92 | ||
|
|
ce1ad032ba | ||
|
|
8446c68e1b | ||
|
|
40ed388b0f | ||
|
|
ce1693aa2f | ||
|
|
11d16a1552 | ||
|
|
0ac54a2c88 | ||
|
|
b7d8b32123 | ||
|
|
5987f6b2cd | ||
|
|
7ad76f5683 | ||
|
|
09a9457021 | ||
|
|
ca4643ec36 | ||
|
|
e2f78ba476 | ||
|
|
5d92190d50 | ||
|
|
2b0d6de986 | ||
|
|
057f82a561 | ||
|
|
719d2a5ffe | ||
|
|
d4bff9d5cb | ||
|
|
19fcc1f93b | ||
|
|
d45ea127c2 | ||
|
|
f591cf8601 | ||
|
|
6661a76aa8 | ||
|
|
a2ed22bfcc | ||
|
|
e370f8891a | ||
|
|
8a83e32c42 | ||
|
|
831eb6325c | ||
|
|
4d6240c987 | ||
|
|
79cf7c84dc | ||
|
|
b71f582329 | ||
|
|
8315d4b6ae | ||
|
|
b8c3cc751a | ||
|
|
d00262dc31 | ||
|
|
3debc6c8d3 | ||
|
|
5092eb58fb | ||
|
|
f0b9240575 | ||
|
|
9cf59c409e | ||
|
|
bfd5aa30a7 | ||
|
|
9737170665 | ||
|
|
922a040466 | ||
|
|
33f0782f3a | ||
|
|
e6a5cef945 | ||
|
|
4c8edb80b3 | ||
|
|
d4668fae99 | ||
|
|
ddfe55e3ae | ||
|
|
761a5f1d4c | ||
|
|
1fbcad8787 | ||
|
|
aba586e605 | ||
|
|
27b21b5ad4 | ||
|
|
b6e54dab17 | ||
|
|
1f8e89772d | ||
|
|
5f3657fd56 | ||
|
|
494162400e | ||
|
|
ab65bb6a8a | ||
|
|
4e1e0cade1 | ||
|
|
fda5904dac | ||
|
|
6d1665004b | ||
|
|
59b8119fbd | ||
|
|
45cd4df6e5 | ||
|
|
d5b6de70da | ||
|
|
d6ade102dc | ||
|
|
c94d246c24 | ||
|
|
5b779ba9fe | ||
|
|
3ba2cb19a9 | ||
|
|
da514ef314 | ||
|
|
7f73cde794 | ||
|
|
b0af0d9cd5 | ||
|
|
8429197b07 | ||
|
|
44f2081882 | ||
|
|
63f7dd1d20 | ||
|
|
57b8c69983 | ||
|
|
aad060810a | ||
|
|
9222b00a6f | ||
|
|
ff61b22e7e | ||
|
|
577cb91343 | ||
|
|
1889386f64 | ||
|
|
5d7f082ebf | ||
|
|
db6327c4ff | ||
|
|
fd7f6b2b99 | ||
|
|
49435398a8 | ||
|
|
9f2fd34e99 | ||
|
|
67b63d3084 | ||
|
|
4a31a7b84b | ||
|
|
538b601b1e | ||
|
|
588f064c25 | ||
|
|
d521e79662 | ||
|
|
ccddb9244d | ||
|
|
0547396213 | ||
|
|
6c85171091 | ||
|
|
0f4d1d2a74 | ||
|
|
941d5c08e3 | ||
|
|
db9f74158b | ||
|
|
609ffccd67 | ||
|
|
748af1d8cb | ||
|
|
d309ec249e | ||
|
|
67949b4968 | ||
|
|
1fc40b3017 | ||
|
|
bb1a375484 | ||
|
|
13c011895d | ||
|
|
bd8d0e3392 | ||
|
|
cda6b67bef | ||
|
|
066305b095 | ||
|
|
89695df012 | ||
|
|
b04385a340 | ||
|
|
d374ea6ea6 | ||
|
|
01a2820390 | ||
|
|
c89c1a03da | ||
|
|
38ac4c5980 | ||
|
|
ed3ee64e4b |
@@ -32,4 +32,5 @@ migrations/
|
||||
config/
|
||||
build.ts
|
||||
tsconfig.json
|
||||
Dockerfile*
|
||||
migrations/
|
||||
|
||||
4
.github/workflows/cicd.yml
vendored
4
.github/workflows/cicd.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -578,7 +578,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
|
||||
2
.github/workflows/restart-runners.yml
vendored
2
.github/workflows/restart-runners.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
|
||||
6
.github/workflows/saas.yml
vendored
6
.github/workflows/saas.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -53,3 +53,4 @@ tsconfig.json
|
||||
hydrateSaas.ts
|
||||
CLAUDE.md
|
||||
drizzle.config.ts
|
||||
server/setup/migrations.ts
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -10,7 +10,7 @@
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
|
||||
63
Dockerfile
63
Dockerfile
@@ -1,33 +1,54 @@
|
||||
FROM node:24-alpine AS builder
|
||||
FROM node:24-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG BUILD=oss
|
||||
ARG DATABASE=sqlite
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# COPY package.json package-lock.json ./
|
||||
COPY package*.json ./
|
||||
|
||||
FROM base AS builder-dev
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG BUILD=oss
|
||||
ARG DATABASE=sqlite
|
||||
|
||||
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
||||
npm run set:$DATABASE && \
|
||||
npm run set:$BUILD && \
|
||||
npm run db:generate && \
|
||||
npm run build && \
|
||||
npm run build:cli
|
||||
npm run build:cli && \
|
||||
test -f dist/server.mjs
|
||||
|
||||
# test to make sure the build output is there and error if not
|
||||
RUN test -f dist/server.mjs
|
||||
FROM base AS builder
|
||||
|
||||
# Prune dev dependencies and clean up to prepare for copy to runner
|
||||
RUN npm prune --omit=dev && npm cache clean --force
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl tzdata
|
||||
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
COPY --from=builder-dev /app/.next/standalone ./
|
||||
COPY --from=builder-dev /app/.next/static ./.next/static
|
||||
COPY --from=builder-dev /app/dist ./dist
|
||||
COPY --from=builder-dev /app/server/migrations ./dist/init
|
||||
|
||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
||||
|
||||
COPY server/db/names.json ./dist/names.json
|
||||
COPY server/db/ios_models.json ./dist/ios_models.json
|
||||
COPY server/db/mac_models.json ./dist/mac_models.json
|
||||
COPY public ./public
|
||||
|
||||
# OCI Image Labels - Build Args for dynamic values
|
||||
ARG VERSION="dev"
|
||||
ARG REVISION=""
|
||||
@@ -38,28 +59,6 @@ ARG LICENSE="AGPL-3.0"
|
||||
ARG IMAGE_TITLE="Pangolin"
|
||||
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only curl and tzdata needed at runtime - no build tools!
|
||||
RUN apk add --no-cache curl tzdata
|
||||
|
||||
# Copy pre-built node_modules from builder (already pruned to production only)
|
||||
# This includes the compiled native modules like better-sqlite3
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/server/migrations ./dist/init
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
||||
|
||||
COPY server/db/names.json ./dist/names.json
|
||||
COPY server/db/ios_models.json ./dist/ios_models.json
|
||||
COPY server/db/mac_models.json ./dist/mac_models.json
|
||||
COPY public ./public
|
||||
|
||||
# OCI Image Labels
|
||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
||||
LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
|
||||
121
cli/commands/generateOrgCaKeys.ts
Normal file
121
cli/commands/generateOrgCaKeys.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -281,7 +281,7 @@ esbuild
|
||||
})
|
||||
],
|
||||
sourcemap: "inline",
|
||||
target: "node22"
|
||||
target: "node24"
|
||||
})
|
||||
.then((result) => {
|
||||
// Check if there were any errors in the build result
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Изберете протокол",
|
||||
"resourcePortNumber": "Номер на порт",
|
||||
"resourcePortNumberDescription": "Външен номер на порт за прокси заявки.",
|
||||
"back": "Назад",
|
||||
"cancel": "Отмяна",
|
||||
"resourceConfig": "Конфигурационни фрагменти",
|
||||
"resourceConfigDescription": "Копирайте и поставете тези конфигурационни отрязъци, за да настроите TCP/UDP ресурса",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Възникна грешка при изтриването на организацията.",
|
||||
"orgDeleted": "Организацията е изтрита",
|
||||
"orgDeletedMessage": "Организацията и нейните данни са изтрити.",
|
||||
"deleteAccount": "Изтриване на профил",
|
||||
"deleteAccountDescription": "Перманентно изтрийте своя профил, всички организации, които притежавате, и всички данни в тези организации. Това не може да бъде отменено.",
|
||||
"deleteAccountButton": "Изтриване на профил",
|
||||
"deleteAccountConfirmTitle": "Изтрий профила",
|
||||
"deleteAccountConfirmMessage": "Това ще изтрие перманентно вашия профил, всички организации, които притежавате, и всички данни в тези организации. Това не може да бъде отменено.",
|
||||
"deleteAccountConfirmString": "изтриване на профил",
|
||||
"deleteAccountSuccess": "Профилът е изтрит",
|
||||
"deleteAccountSuccessMessage": "Вашият профил е изтрит.",
|
||||
"deleteAccountError": "Неуспешно изтриване на профил",
|
||||
"deleteAccountPreviewAccount": "Вашият профил",
|
||||
"deleteAccountPreviewOrgs": "Организации, които притежавате (и всички техни данни)",
|
||||
"orgMissing": "Липсва идентификатор на организация",
|
||||
"orgMissingMessage": "Невъзможност за регенериране на покана без идентификатор на организация.",
|
||||
"accessUsersManage": "Управление на потребители",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Филтрирайте по състояние на одобрение",
|
||||
"approvalListEmpty": "Няма одобрения",
|
||||
"approvalState": "Състояние на одобрение",
|
||||
"approvalLoadMore": "Заредете още",
|
||||
"loadingApprovals": "Зарежда се одобрение",
|
||||
"approve": "Одобряване",
|
||||
"approved": "Одобрен",
|
||||
"denied": "Отказан",
|
||||
@@ -776,6 +790,7 @@
|
||||
"accessRoleRemoved": "Ролята е премахната",
|
||||
"accessRoleRemovedDescription": "Ролята беше успешно премахната.",
|
||||
"accessRoleRequiredRemove": "Преди да изтриете тази роля, моля изберете нова роля, към която да прехвърлите настоящите членове.",
|
||||
"network": "Мрежа",
|
||||
"manage": "Управление",
|
||||
"sitesNotFound": "Няма намерени сайтове.",
|
||||
"pangolinServerAdmin": "Администратор на сървър - Панголин",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Настройка - Pangolin",
|
||||
"orgNameRequired": "Името на организацията е задължително",
|
||||
"orgIdRequired": "ID на организацията е задължително",
|
||||
"orgIdMaxLength": "ID на организация трябва да бъде най-много 32 символа",
|
||||
"orgErrorCreate": "Възникна грешка при създаване на организация",
|
||||
"pageNotFound": "Страницата не е намерена",
|
||||
"pageNotFoundDescription": "О, не! Страницата, която търсите, не съществува.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Преглед на дневници",
|
||||
"noneSelected": "Нищо не е избрано",
|
||||
"orgNotFound2": "Няма намерени организации.",
|
||||
"searchProgress": "Търсене...",
|
||||
"searchPlaceholder": "Търсене...",
|
||||
"emptySearchOptions": "Няма намерени опции",
|
||||
"create": "Създаване",
|
||||
"orgs": "Организации",
|
||||
"loginError": "Възникна неочаквана грешка. Моля, опитайте отново.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Частно",
|
||||
"sidebarAccessControl": "Контрол на достъпа",
|
||||
"sidebarLogsAndAnalytics": "Дневници и анализи",
|
||||
"sidebarTeam": "Екип",
|
||||
"sidebarUsers": "Потребители",
|
||||
"sidebarAdmin": "Администратор",
|
||||
"sidebarInvitations": "Покани",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Лог & Анализи",
|
||||
"sidebarBluePrints": "Чертежи",
|
||||
"sidebarOrganization": "Организация",
|
||||
"sidebarManagement": "Управление",
|
||||
"sidebarBillingAndLicenses": "Фактуриране & Лицензи",
|
||||
"sidebarLogsAnalytics": "Анализи",
|
||||
"blueprints": "Чертежи",
|
||||
"blueprintsDescription": "Прилагайте декларативни конфигурации и преглеждайте предишни изпълнения",
|
||||
@@ -1272,7 +1292,6 @@
|
||||
"parsedContents": "Парсирано съдържание (само за четене)",
|
||||
"enableDockerSocket": "Активиране на Docker Чернова",
|
||||
"enableDockerSocketDescription": "Активиране на Docker Socket маркировка за изтегляне на етикети на чернова. Пътят на гнездото трябва да бъде предоставен на Newt.",
|
||||
"enableDockerSocketLink": "Научете повече",
|
||||
"viewDockerContainers": "Преглед на Docker контейнери",
|
||||
"containersIn": "Контейнери в {siteName}",
|
||||
"selectContainerDescription": "Изберете контейнер, който да ползвате като име на хост за целта. Натиснете порт, за да ползвате порт",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Сайтове",
|
||||
"billingUsers": "Потребители",
|
||||
"billingDomains": "Домейни",
|
||||
"billingOrganizations": "Организации",
|
||||
"billingRemoteExitNodes": "Дистанционни възли",
|
||||
"billingNoLimitConfigured": "Няма конфигуриран лимит",
|
||||
"billingEstimatedPeriod": "Очакван период на фактуриране",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Неуспешно",
|
||||
"createNewOrgDescription": "Създайте нова организация",
|
||||
"organization": "Организация",
|
||||
"primary": "Основно",
|
||||
"port": "Порт",
|
||||
"securityKeyManage": "Управление на ключове за защита",
|
||||
"securityKeyDescription": "Добавяне или премахване на ключове за защита за удостоверяване без парола",
|
||||
@@ -1624,6 +1645,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 се счита за здравословно.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Сигурни ли сте, че искате да премахнете брандинга за страниците за автентификация?",
|
||||
"authPageBrandingDeleteConfirm": "Потвърждение на изтриване на брандинга.",
|
||||
"brandingLogoURL": "URL адрес на логото.",
|
||||
"brandingLogoURLOrPath": "URL или Път към лого",
|
||||
"brandingLogoPathDescription": "Въведете URL или локален път.",
|
||||
"brandingLogoURLDescription": "Въведете публично достъпен URL към вашето лого изображение.",
|
||||
"brandingPrimaryColor": "Основен цвят.",
|
||||
"brandingLogoWidth": "Ширина (px).",
|
||||
"brandingLogoHeight": "Височина (px).",
|
||||
@@ -2481,6 +2523,17 @@
|
||||
"editInternalResourceDialogAccessControl": "Контрол на достъпа.",
|
||||
"editInternalResourceDialogAccessControlDescription": "Контролирайте кои роли, потребители и клиентски машини имат достъп до този ресурс, когато са свързани. Администраторите винаги имат достъп.",
|
||||
"editInternalResourceDialogPortRangeValidationError": "Обхватът на портовете трябва да е \"*\" за всички портове или списък от разделени със запетая портове и диапазони (например: \"80,443,8000-9000\"). Портовете трябва да са между 1 и 65535.",
|
||||
"internalResourceAuthDaemonStrategy": "Локация на SSH Auth Daemon",
|
||||
"internalResourceAuthDaemonStrategyDescription": "Изберете къде ще работи демонът за SSH удостоверение: на сайта (Newt) или на отдалечен хост.",
|
||||
"internalResourceAuthDaemonDescription": "Демонът за SSH удостоверение управлява подписването на SSH ключове и PAM удостоверение за този ресурс. Изберете дали да работи на сайта (Newt) или на отделен отдалечен хост. Вижте <docsLink>документацията</docsLink> за повече информация.",
|
||||
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
|
||||
"internalResourceAuthDaemonStrategyPlaceholder": "Изберете стратегия",
|
||||
"internalResourceAuthDaemonStrategyLabel": "Местоположение",
|
||||
"internalResourceAuthDaemonSite": "На сайта",
|
||||
"internalResourceAuthDaemonSiteDescription": "Демонът за удостоверение работи на сайта (Newt).",
|
||||
"internalResourceAuthDaemonRemote": "Отдалечен хост",
|
||||
"internalResourceAuthDaemonRemoteDescription": "Демонът за удостоверение работи на хост, който не е сайтът.",
|
||||
"internalResourceAuthDaemonPort": "Порт на демона (незадължителен)",
|
||||
"orgAuthWhatsThis": "Къде мога да намеря идентификатора на организацията си?",
|
||||
"learnMore": "Научете повече.",
|
||||
"backToHome": "Връщане към началната страница.",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Vybrat protokol",
|
||||
"resourcePortNumber": "Číslo portu",
|
||||
"resourcePortNumberDescription": "Externí port k požadavkům proxy serveru.",
|
||||
"back": "Zpět",
|
||||
"cancel": "Zrušit",
|
||||
"resourceConfig": "Konfigurační snippety",
|
||||
"resourceConfigDescription": "Zkopírujte a vložte tyto konfigurační textové bloky pro nastavení TCP/UDP zdroje",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Došlo k chybě při odstraňování organizace.",
|
||||
"orgDeleted": "Organizace odstraněna",
|
||||
"orgDeletedMessage": "Organizace a její data byla smazána.",
|
||||
"deleteAccount": "Odstranit účet",
|
||||
"deleteAccountDescription": "Trvale smazat svůj účet, všechny organizace, které vlastníte, a všechna data těchto organizací. Tuto akci nelze vrátit zpět.",
|
||||
"deleteAccountButton": "Odstranit účet",
|
||||
"deleteAccountConfirmTitle": "Odstranit účet",
|
||||
"deleteAccountConfirmMessage": "Toto trvale vymaže váš účet, všechny organizace, které vlastníte, a všechna data v rámci těchto organizací. Tuto akci nelze vrátit zpět.",
|
||||
"deleteAccountConfirmString": "smazat účet",
|
||||
"deleteAccountSuccess": "Účet odstraněn",
|
||||
"deleteAccountSuccessMessage": "Váš účet byl odstraněn.",
|
||||
"deleteAccountError": "Nepodařilo se odstranit účet",
|
||||
"deleteAccountPreviewAccount": "Váš účet",
|
||||
"deleteAccountPreviewOrgs": "Organizace, které vlastníte (a všechny jejich údaje)",
|
||||
"orgMissing": "Chybí ID organizace",
|
||||
"orgMissingMessage": "Nelze obnovit pozvánku bez ID organizace.",
|
||||
"accessUsersManage": "Spravovat uživatele",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Filtrovat podle státu schválení",
|
||||
"approvalListEmpty": "Žádná schválení",
|
||||
"approvalState": "Země schválení",
|
||||
"approvalLoadMore": "Načíst více",
|
||||
"loadingApprovals": "Načítání schválení",
|
||||
"approve": "Schválit",
|
||||
"approved": "Schváleno",
|
||||
"denied": "Zamítnuto",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Setup - Pangolin",
|
||||
"orgNameRequired": "Je vyžadován název organizace",
|
||||
"orgIdRequired": "Je vyžadováno ID organizace",
|
||||
"orgIdMaxLength": "ID organizace musí mít nejvýše 32 znaků",
|
||||
"orgErrorCreate": "Při vytváření org došlo k chybě",
|
||||
"pageNotFound": "Stránka nenalezena",
|
||||
"pageNotFoundDescription": "Jejda! Stránka, kterou hledáte, neexistuje.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Zobrazit logy",
|
||||
"noneSelected": "Není vybráno",
|
||||
"orgNotFound2": "Nebyly nalezeny žádné organizace.",
|
||||
"searchProgress": "Hledat...",
|
||||
"searchPlaceholder": "Hledat...",
|
||||
"emptySearchOptions": "Nebyly nalezeny žádné možnosti",
|
||||
"create": "Vytvořit",
|
||||
"orgs": "Organizace",
|
||||
"loginError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Soukromé",
|
||||
"sidebarAccessControl": "Kontrola přístupu",
|
||||
"sidebarLogsAndAnalytics": "Logy & Analytika",
|
||||
"sidebarTeam": "Tým",
|
||||
"sidebarUsers": "Uživatelé",
|
||||
"sidebarAdmin": "Admin",
|
||||
"sidebarInvitations": "Pozvánky",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||
"sidebarBluePrints": "Plány",
|
||||
"sidebarOrganization": "Organizace",
|
||||
"sidebarManagement": "Správa",
|
||||
"sidebarBillingAndLicenses": "Fakturace a licence",
|
||||
"sidebarLogsAnalytics": "Analytici",
|
||||
"blueprints": "Plány",
|
||||
"blueprintsDescription": "Použít deklarativní konfigurace a zobrazit předchozí běhy",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Stránky",
|
||||
"billingUsers": "Uživatelé",
|
||||
"billingDomains": "Domény",
|
||||
"billingOrganizations": "Tělo",
|
||||
"billingRemoteExitNodes": "Vzdálené uzly",
|
||||
"billingNoLimitConfigured": "Žádný limit nenastaven",
|
||||
"billingEstimatedPeriod": "Odhadované období fakturace",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Selhalo",
|
||||
"createNewOrgDescription": "Vytvořit novou organizaci",
|
||||
"organization": "Organizace",
|
||||
"primary": "Primární",
|
||||
"port": "Přístav",
|
||||
"securityKeyManage": "Správa bezpečnostních klíčů",
|
||||
"securityKeyDescription": "Přidat nebo odebrat bezpečnostní klíče pro bezheslou autentizaci",
|
||||
@@ -1624,6 +1645,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é.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Jste si jisti, že chcete odstranit branding autentizačních stránek?",
|
||||
"authPageBrandingDeleteConfirm": "Potvrzení odstranění brandingu",
|
||||
"brandingLogoURL": "URL loga",
|
||||
"brandingLogoURLOrPath": "URL nebo cesta k logu",
|
||||
"brandingLogoPathDescription": "Zadejte URL nebo místní cestu.",
|
||||
"brandingLogoURLDescription": "Zadejte veřejně přístupnou adresu URL vašeho loga.",
|
||||
"brandingPrimaryColor": "Primární barva",
|
||||
"brandingLogoWidth": "Šířka (px)",
|
||||
"brandingLogoHeight": "Výška (px)",
|
||||
@@ -2481,6 +2523,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 <docsLink>dokumentaci</docsLink>.",
|
||||
"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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Wählen Sie ein Protokoll",
|
||||
"resourcePortNumber": "Portnummer",
|
||||
"resourcePortNumberDescription": "Die externe Portnummer für Proxy-Anfragen.",
|
||||
"back": "Zurück",
|
||||
"cancel": "Abbrechen",
|
||||
"resourceConfig": "Konfiguration Snippets",
|
||||
"resourceConfigDescription": "Kopieren und fügen Sie diese Konfigurations-Snippets ein, um die TCP/UDP Ressource einzurichten",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Beim Löschen der Organisation ist ein Fehler aufgetreten.",
|
||||
"orgDeleted": "Organisation gelöscht",
|
||||
"orgDeletedMessage": "Die Organisation und ihre Daten wurden gelöscht.",
|
||||
"deleteAccount": "Konto löschen",
|
||||
"deleteAccountDescription": "Lösche dein Konto, alle Organisationen, die du besitzt, und alle Daten innerhalb dieser Organisationen. Dies kann nicht rückgängig gemacht werden.",
|
||||
"deleteAccountButton": "Konto löschen",
|
||||
"deleteAccountConfirmTitle": "Konto löschen",
|
||||
"deleteAccountConfirmMessage": "Dies wird Ihr Konto dauerhaft löschen, alle Organisationen, die Sie besitzen, und alle Daten innerhalb dieser Organisationen. Dies kann nicht rückgängig gemacht werden.",
|
||||
"deleteAccountConfirmString": "Konto löschen",
|
||||
"deleteAccountSuccess": "Konto gelöscht",
|
||||
"deleteAccountSuccessMessage": "Ihr Konto wurde gelöscht.",
|
||||
"deleteAccountError": "Konto konnte nicht gelöscht werden",
|
||||
"deleteAccountPreviewAccount": "Ihr Konto",
|
||||
"deleteAccountPreviewOrgs": "Organisationen, die Sie besitzen (und ihre Daten)",
|
||||
"orgMissing": "Organisations-ID fehlt",
|
||||
"orgMissingMessage": "Einladung kann ohne Organisations-ID nicht neu generiert werden.",
|
||||
"accessUsersManage": "Benutzer verwalten",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Filtern nach Genehmigungsstatus",
|
||||
"approvalListEmpty": "Keine Genehmigungen",
|
||||
"approvalState": "Genehmigungsstatus",
|
||||
"approvalLoadMore": "Mehr laden",
|
||||
"loadingApprovals": "Genehmigungen werden geladen",
|
||||
"approve": "Bestätigen",
|
||||
"approved": "Genehmigt",
|
||||
"denied": "Verweigert",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Einrichtung - Pangolin",
|
||||
"orgNameRequired": "Organisationsname ist erforderlich",
|
||||
"orgIdRequired": "Organisations-ID ist erforderlich",
|
||||
"orgIdMaxLength": "Organisations-ID darf höchstens 32 Zeichen lang sein",
|
||||
"orgErrorCreate": "Beim Erstellen der Organisation ist ein Fehler aufgetreten",
|
||||
"pageNotFound": "Seite nicht gefunden",
|
||||
"pageNotFoundDescription": "Hoppla! Die gesuchte Seite existiert nicht.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Logs anzeigen",
|
||||
"noneSelected": "Keine ausgewählt",
|
||||
"orgNotFound2": "Keine Organisationen gefunden.",
|
||||
"searchProgress": "Suche...",
|
||||
"searchPlaceholder": "Suche...",
|
||||
"emptySearchOptions": "Keine Optionen gefunden",
|
||||
"create": "Erstellen",
|
||||
"orgs": "Organisationen",
|
||||
"loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Privat",
|
||||
"sidebarAccessControl": "Zugriffskontrolle",
|
||||
"sidebarLogsAndAnalytics": "Protokolle & Analysen",
|
||||
"sidebarTeam": "Team",
|
||||
"sidebarUsers": "Benutzer",
|
||||
"sidebarAdmin": "Admin",
|
||||
"sidebarInvitations": "Einladungen",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Log & Analytik",
|
||||
"sidebarBluePrints": "Blaupausen",
|
||||
"sidebarOrganization": "Organisation",
|
||||
"sidebarManagement": "Management",
|
||||
"sidebarBillingAndLicenses": "Abrechnung & Lizenzen",
|
||||
"sidebarLogsAnalytics": "Analytik",
|
||||
"blueprints": "Blaupausen",
|
||||
"blueprintsDescription": "Deklarative Konfigurationen anwenden und vorherige Abläufe anzeigen",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Seiten",
|
||||
"billingUsers": "Benutzergeräte",
|
||||
"billingDomains": "Domänen",
|
||||
"billingOrganizations": "Orden",
|
||||
"billingRemoteExitNodes": "Entfernte Knoten",
|
||||
"billingNoLimitConfigured": "Kein Limit konfiguriert",
|
||||
"billingEstimatedPeriod": "Geschätzter Abrechnungszeitraum",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Fehlgeschlagen",
|
||||
"createNewOrgDescription": "Eine neue Organisation erstellen",
|
||||
"organization": "Organisation",
|
||||
"primary": "Primär",
|
||||
"port": "Port",
|
||||
"securityKeyManage": "Sicherheitsschlüssel verwalten",
|
||||
"securityKeyDescription": "Sicherheitsschlüssel für passwortlose Authentifizierung hinzufügen oder entfernen",
|
||||
@@ -1624,6 +1645,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.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Sind Sie sicher, dass Sie das Branding für Authentifizierungsseiten entfernen möchten?",
|
||||
"authPageBrandingDeleteConfirm": "Branding löschen bestätigen",
|
||||
"brandingLogoURL": "Logo URL",
|
||||
"brandingLogoURLOrPath": "Logo-URL oder Pfad",
|
||||
"brandingLogoPathDescription": "Geben Sie eine URL oder einen lokalen Pfad ein.",
|
||||
"brandingLogoURLDescription": "Geben Sie eine öffentlich zugängliche URL zu Ihrem Logobild ein.",
|
||||
"brandingPrimaryColor": "Primär-Farbe",
|
||||
"brandingLogoWidth": "Breite (px)",
|
||||
"brandingLogoHeight": "Höhe (px)",
|
||||
@@ -2481,6 +2523,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 <docsLink>die Dokumentation</docsLink> 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",
|
||||
|
||||
@@ -473,6 +473,8 @@
|
||||
"filterByApprovalState": "Filter By Approval State",
|
||||
"approvalListEmpty": "No approvals",
|
||||
"approvalState": "Approval State",
|
||||
"approvalLoadMore": "Load more",
|
||||
"loadingApprovals": "Loading Approvals",
|
||||
"approve": "Approve",
|
||||
"approved": "Approved",
|
||||
"denied": "Denied",
|
||||
@@ -647,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",
|
||||
@@ -788,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",
|
||||
@@ -1029,6 +1032,7 @@
|
||||
"pangolinSetup": "Setup - Pangolin",
|
||||
"orgNameRequired": "Organization name is required",
|
||||
"orgIdRequired": "Organization ID is required",
|
||||
"orgIdMaxLength": "Organization ID must be at most 32 characters",
|
||||
"orgErrorCreate": "An error occurred while creating org",
|
||||
"pageNotFound": "Page Not Found",
|
||||
"pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.",
|
||||
@@ -1181,7 +1185,8 @@
|
||||
"actionViewLogs": "View Logs",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"searchProgress": "Search...",
|
||||
"searchPlaceholder": "Search...",
|
||||
"emptySearchOptions": "No options found",
|
||||
"create": "Create",
|
||||
"orgs": "Organizations",
|
||||
"loginError": "An unexpected error occurred. Please try again.",
|
||||
@@ -1245,6 +1250,7 @@
|
||||
"sidebarClientResources": "Private",
|
||||
"sidebarAccessControl": "Access Control",
|
||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||
"sidebarTeam": "Team",
|
||||
"sidebarUsers": "Users",
|
||||
"sidebarAdmin": "Admin",
|
||||
"sidebarInvitations": "Invitations",
|
||||
@@ -1263,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||
"sidebarBluePrints": "Blueprints",
|
||||
"sidebarOrganization": "Organization",
|
||||
"sidebarManagement": "Management",
|
||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||
"sidebarLogsAnalytics": "Analytics",
|
||||
"blueprints": "Blueprints",
|
||||
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
||||
@@ -1283,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 <docsLink>the documentation</docsLink>.",
|
||||
"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.",
|
||||
@@ -1424,6 +1431,7 @@
|
||||
"billingSites": "Sites",
|
||||
"billingUsers": "Users",
|
||||
"billingDomains": "Domains",
|
||||
"billingOrganizations": "Orgs",
|
||||
"billingRemoteExitNodes": "Remote Nodes",
|
||||
"billingNoLimitConfigured": "No limit configured",
|
||||
"billingEstimatedPeriod": "Estimated Billing Period",
|
||||
@@ -1466,6 +1474,7 @@
|
||||
"failed": "Failed",
|
||||
"createNewOrgDescription": "Create a new organization",
|
||||
"organization": "Organization",
|
||||
"primary": "Primary",
|
||||
"port": "Port",
|
||||
"securityKeyManage": "Manage Security Keys",
|
||||
"securityKeyDescription": "Add or remove security keys for passwordless authentication",
|
||||
@@ -1636,6 +1645,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.",
|
||||
@@ -1928,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
|
||||
"authPageBrandingDeleteConfirm": "Confirm Delete Branding",
|
||||
"brandingLogoURL": "Logo URL",
|
||||
"brandingLogoURLOrPath": "Logo URL or Path",
|
||||
"brandingLogoPathDescription": "Enter a URL or a local path.",
|
||||
"brandingLogoURLDescription": "Enter a publicly accessible URL to your logo image.",
|
||||
"brandingPrimaryColor": "Primary Color",
|
||||
"brandingLogoWidth": "Width (px)",
|
||||
"brandingLogoHeight": "Height (px)",
|
||||
@@ -1978,8 +2008,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 <tierLink>{tier}</tierLink> or higher.",
|
||||
"upgradeToTierToUse": "Upgrade to <tierLink>{tier}</tierLink> or higher to use this feature.",
|
||||
"subscriptionRequiredTierToUse": "This feature requires <tierLink>{tier}</tierLink>.",
|
||||
"upgradeToTierToUse": "Upgrade to <tierLink>{tier}</tierLink> to use this feature.",
|
||||
"subscriptionTierTier1": "Home",
|
||||
"subscriptionTierTier2": "Team",
|
||||
"subscriptionTierTier3": "Business",
|
||||
@@ -2069,7 +2099,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",
|
||||
@@ -2295,7 +2325,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 <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature.",
|
||||
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
||||
"certResolver": "Certificate Resolver",
|
||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||
@@ -2493,6 +2523,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 <docsLink>the documentation</docsLink> 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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Seleccionar un protocolo",
|
||||
"resourcePortNumber": "Número de puerto",
|
||||
"resourcePortNumberDescription": "El número de puerto externo a las solicitudes de proxy.",
|
||||
"back": "Atrás",
|
||||
"cancel": "Cancelar",
|
||||
"resourceConfig": "Fragmentos de configuración",
|
||||
"resourceConfigDescription": "Copia y pega estos fragmentos de configuración para configurar el recurso TCP/UDP",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Se ha producido un error al eliminar la organización.",
|
||||
"orgDeleted": "Organización eliminada",
|
||||
"orgDeletedMessage": "La organización y sus datos han sido eliminados.",
|
||||
"deleteAccount": "Eliminar cuenta",
|
||||
"deleteAccountDescription": "Elimina permanentemente tu cuenta, todas las organizaciones que posees y todos los datos dentro de esas organizaciones. Esto no se puede deshacer.",
|
||||
"deleteAccountButton": "Eliminar cuenta",
|
||||
"deleteAccountConfirmTitle": "Eliminar cuenta",
|
||||
"deleteAccountConfirmMessage": "Esto borrará permanentemente tu cuenta, todas las organizaciones que posees y todos los datos dentro de esas organizaciones. Esto no se puede deshacer.",
|
||||
"deleteAccountConfirmString": "eliminar cuenta",
|
||||
"deleteAccountSuccess": "Cuenta eliminada",
|
||||
"deleteAccountSuccessMessage": "Tu cuenta ha sido eliminada.",
|
||||
"deleteAccountError": "Error al eliminar la cuenta",
|
||||
"deleteAccountPreviewAccount": "Tu cuenta",
|
||||
"deleteAccountPreviewOrgs": "Organizaciones que tienes (y todos sus datos)",
|
||||
"orgMissing": "Falta el ID de la organización",
|
||||
"orgMissingMessage": "No se puede regenerar la invitación sin el ID de la organización.",
|
||||
"accessUsersManage": "Administrar usuarios",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Filtrar por estado de aprobación",
|
||||
"approvalListEmpty": "No hay aprobaciones",
|
||||
"approvalState": "Estado de aprobación",
|
||||
"approvalLoadMore": "Cargar más",
|
||||
"loadingApprovals": "Cargando aprobaciones",
|
||||
"approve": "Aprobar",
|
||||
"approved": "Aprobado",
|
||||
"denied": "Denegado",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Configuración - Pangolin",
|
||||
"orgNameRequired": "El nombre de la organización es obligatorio",
|
||||
"orgIdRequired": "El ID de la organización es obligatorio",
|
||||
"orgIdMaxLength": "El ID de la organización debe tener como máximo 32 caracteres",
|
||||
"orgErrorCreate": "Se ha producido un error al crear el org",
|
||||
"pageNotFound": "Página no encontrada",
|
||||
"pageNotFoundDescription": "¡Vaya! La página que estás buscando no existe.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Ver registros",
|
||||
"noneSelected": "Ninguno seleccionado",
|
||||
"orgNotFound2": "No se encontraron organizaciones.",
|
||||
"searchProgress": "Buscar...",
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"emptySearchOptions": "No se encontraron opciones",
|
||||
"create": "Crear",
|
||||
"orgs": "Organizaciones",
|
||||
"loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Privado",
|
||||
"sidebarAccessControl": "Control de acceso",
|
||||
"sidebarLogsAndAnalytics": "Registros y análisis",
|
||||
"sidebarTeam": "Equipo",
|
||||
"sidebarUsers": "Usuarios",
|
||||
"sidebarAdmin": "Admin",
|
||||
"sidebarInvitations": "Invitaciones",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Registro y análisis",
|
||||
"sidebarBluePrints": "Planos",
|
||||
"sidebarOrganization": "Organización",
|
||||
"sidebarManagement": "Gestión",
|
||||
"sidebarBillingAndLicenses": "Facturación y licencias",
|
||||
"sidebarLogsAnalytics": "Analíticas",
|
||||
"blueprints": "Planos",
|
||||
"blueprintsDescription": "Aplicar configuraciones declarativas y ver ejecuciones anteriores",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Sitios",
|
||||
"billingUsers": "Usuarios",
|
||||
"billingDomains": "Dominios",
|
||||
"billingOrganizations": "Orgánico",
|
||||
"billingRemoteExitNodes": "Nodos remotos",
|
||||
"billingNoLimitConfigured": "No se ha configurado ningún límite",
|
||||
"billingEstimatedPeriod": "Período de facturación estimado",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Fallido",
|
||||
"createNewOrgDescription": "Crear una nueva organización",
|
||||
"organization": "Organización",
|
||||
"primary": "Principal",
|
||||
"port": "Puerto",
|
||||
"securityKeyManage": "Gestionar llaves de seguridad",
|
||||
"securityKeyDescription": "Agregar o eliminar llaves de seguridad para autenticación sin contraseña",
|
||||
@@ -1624,6 +1645,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.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "¿Está seguro de que desea eliminar la marca de las páginas de autenticación?",
|
||||
"authPageBrandingDeleteConfirm": "Confirmar eliminación de la marca",
|
||||
"brandingLogoURL": "URL del logotipo",
|
||||
"brandingLogoURLOrPath": "URL o ruta de Logo",
|
||||
"brandingLogoPathDescription": "Introduzca una URL o una ruta local.",
|
||||
"brandingLogoURLDescription": "Introduzca una URL de acceso público a su imagen de logotipo.",
|
||||
"brandingPrimaryColor": "Color primario",
|
||||
"brandingLogoWidth": "Ancho (px)",
|
||||
"brandingLogoHeight": "Altura (px)",
|
||||
@@ -2481,6 +2523,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 <docsLink>la documentación</docsLink> 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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Choisir un protocole",
|
||||
"resourcePortNumber": "Numéro de port",
|
||||
"resourcePortNumberDescription": "Le numéro de port externe pour les requêtes de proxy.",
|
||||
"back": "Précédent",
|
||||
"cancel": "Abandonner",
|
||||
"resourceConfig": "Snippets de configuration",
|
||||
"resourceConfigDescription": "Copiez et collez ces extraits de configuration pour configurer la ressource TCP/UDP",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Une erreur s'est produite lors de la suppression de l'organisation.",
|
||||
"orgDeleted": "Organisation supprimée",
|
||||
"orgDeletedMessage": "L'organisation et ses données ont été supprimées.",
|
||||
"deleteAccount": "Supprimer le compte",
|
||||
"deleteAccountDescription": "Supprimer définitivement votre compte, toutes les organisations que vous possédez et toutes les données au sein de ces organisations. Cela ne peut pas être annulé.",
|
||||
"deleteAccountButton": "Supprimer le compte",
|
||||
"deleteAccountConfirmTitle": "Supprimer le compte",
|
||||
"deleteAccountConfirmMessage": "Cela effacera définitivement votre compte, toutes les organisations que vous possédez et toutes les données au sein de ces organisations. Cela ne peut pas être annulé.",
|
||||
"deleteAccountConfirmString": "supprimer le compte",
|
||||
"deleteAccountSuccess": "Compte supprimé",
|
||||
"deleteAccountSuccessMessage": "Votre compte a été supprimé.",
|
||||
"deleteAccountError": "Échec de la suppression du compte",
|
||||
"deleteAccountPreviewAccount": "Votre Compte",
|
||||
"deleteAccountPreviewOrgs": "Organisations que vous possédez (et toutes leurs données)",
|
||||
"orgMissing": "ID d'organisation manquant",
|
||||
"orgMissingMessage": "Impossible de régénérer l'invitation sans un ID d'organisation.",
|
||||
"accessUsersManage": "Gérer les utilisateurs",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Filtrer par État d'Approbation",
|
||||
"approvalListEmpty": "Aucune approbation",
|
||||
"approvalState": "État d'approbation",
|
||||
"approvalLoadMore": "Charger plus",
|
||||
"loadingApprovals": "Chargement des approbations",
|
||||
"approve": "Approuver",
|
||||
"approved": "Approuvé",
|
||||
"denied": "Refusé",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Configuration - Pangolin",
|
||||
"orgNameRequired": "Le nom de l'organisation est requis",
|
||||
"orgIdRequired": "L'ID de l'organisation est requis",
|
||||
"orgIdMaxLength": "L'identifiant de l'organisation doit comporter au plus 32 caractères",
|
||||
"orgErrorCreate": "Une erreur s'est produite lors de la création de l'organisation",
|
||||
"pageNotFound": "Page non trouvée",
|
||||
"pageNotFoundDescription": "Oups! La page que vous recherchez n'existe pas.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Voir les logs",
|
||||
"noneSelected": "Aucune sélection",
|
||||
"orgNotFound2": "Aucune organisation trouvée.",
|
||||
"searchProgress": "Rechercher...",
|
||||
"searchPlaceholder": "Recherche...",
|
||||
"emptySearchOptions": "Aucune option trouvée",
|
||||
"create": "Créer",
|
||||
"orgs": "Organisations",
|
||||
"loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Privé",
|
||||
"sidebarAccessControl": "Contrôle d'accès",
|
||||
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
|
||||
"sidebarTeam": "Equipe",
|
||||
"sidebarUsers": "Utilisateurs",
|
||||
"sidebarAdmin": "Administrateur",
|
||||
"sidebarInvitations": "Invitations",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Journaux & Analytiques",
|
||||
"sidebarBluePrints": "Configs",
|
||||
"sidebarOrganization": "Organisation",
|
||||
"sidebarManagement": "Gestion",
|
||||
"sidebarBillingAndLicenses": "Facturation & Licences",
|
||||
"sidebarLogsAnalytics": "Analyses",
|
||||
"blueprints": "Configs",
|
||||
"blueprintsDescription": "Appliquer les configurations déclaratives et afficher les exécutions précédentes",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Nœuds",
|
||||
"billingUsers": "Utilisateurs",
|
||||
"billingDomains": "Domaines",
|
||||
"billingOrganizations": "Organes",
|
||||
"billingRemoteExitNodes": "Nœuds distants",
|
||||
"billingNoLimitConfigured": "Aucune limite configurée",
|
||||
"billingEstimatedPeriod": "Période de facturation estimée",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Échec",
|
||||
"createNewOrgDescription": "Créer une nouvelle organisation",
|
||||
"organization": "Organisation",
|
||||
"primary": "Primaire",
|
||||
"port": "Port",
|
||||
"securityKeyManage": "Gérer les clés de sécurité",
|
||||
"securityKeyDescription": "Ajouter ou supprimer des clés de sécurité pour l'authentification sans mot de passe",
|
||||
@@ -1624,6 +1645,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.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Êtes-vous sûr de vouloir supprimer la marque des pages d'authentification ?",
|
||||
"authPageBrandingDeleteConfirm": "Confirmer la suppression de la marque",
|
||||
"brandingLogoURL": "URL du logo",
|
||||
"brandingLogoURLOrPath": "URL du logo ou du chemin d'accès",
|
||||
"brandingLogoPathDescription": "Entrez une URL ou un chemin local.",
|
||||
"brandingLogoURLDescription": "Entrez une URL accessible au public à votre image de logo.",
|
||||
"brandingPrimaryColor": "Couleur principale",
|
||||
"brandingLogoWidth": "Largeur (px)",
|
||||
"brandingLogoHeight": "Hauteur (px)",
|
||||
@@ -2481,6 +2523,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 <docsLink>la documentation</docsLink> 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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Seleziona un protocollo",
|
||||
"resourcePortNumber": "Numero Porta",
|
||||
"resourcePortNumberDescription": "Il numero di porta esterna per le richieste di proxy.",
|
||||
"back": "Indietro",
|
||||
"cancel": "Annulla",
|
||||
"resourceConfig": "Snippet Di Configurazione",
|
||||
"resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Si è verificato un errore durante l'eliminazione dell'organizzazione.",
|
||||
"orgDeleted": "Organizzazione eliminata",
|
||||
"orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.",
|
||||
"deleteAccount": "Elimina Account",
|
||||
"deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.",
|
||||
"deleteAccountButton": "Elimina Account",
|
||||
"deleteAccountConfirmTitle": "Elimina Account",
|
||||
"deleteAccountConfirmMessage": "Questo cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.",
|
||||
"deleteAccountConfirmString": "elimina account",
|
||||
"deleteAccountSuccess": "Account Eliminato",
|
||||
"deleteAccountSuccessMessage": "Il tuo account è stato eliminato.",
|
||||
"deleteAccountError": "Impossibile eliminare l'account",
|
||||
"deleteAccountPreviewAccount": "Il Tuo Account",
|
||||
"deleteAccountPreviewOrgs": "Organizzazioni che possiedi (e tutti i loro dati)",
|
||||
"orgMissing": "ID Organizzazione Mancante",
|
||||
"orgMissingMessage": "Impossibile rigenerare l'invito senza un ID organizzazione.",
|
||||
"accessUsersManage": "Gestisci Utenti",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Filtra Per Stato Di Approvazione",
|
||||
"approvalListEmpty": "Nessuna approvazione",
|
||||
"approvalState": "Stato Di Approvazione",
|
||||
"approvalLoadMore": "Carica altro",
|
||||
"loadingApprovals": "Caricamento Approvazioni",
|
||||
"approve": "Approva",
|
||||
"approved": "Approvato",
|
||||
"denied": "Negato",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Configurazione - Pangolin",
|
||||
"orgNameRequired": "Il nome dell'organizzazione è obbligatorio",
|
||||
"orgIdRequired": "L'ID dell'organizzazione è obbligatorio",
|
||||
"orgIdMaxLength": "L'ID dell'organizzazione deve contenere al massimo 32 caratteri",
|
||||
"orgErrorCreate": "Si è verificato un errore durante la creazione dell'organizzazione",
|
||||
"pageNotFound": "Pagina Non Trovata",
|
||||
"pageNotFoundDescription": "Oops! La pagina che stai cercando non esiste.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Visualizza Log",
|
||||
"noneSelected": "Nessuna selezione",
|
||||
"orgNotFound2": "Nessuna organizzazione trovata.",
|
||||
"searchProgress": "Ricerca...",
|
||||
"searchPlaceholder": "Cerca...",
|
||||
"emptySearchOptions": "Nessuna opzione trovata",
|
||||
"create": "Crea",
|
||||
"orgs": "Organizzazioni",
|
||||
"loginError": "Si è verificato un errore imprevisto. Riprova.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Privato",
|
||||
"sidebarAccessControl": "Controllo Accesso",
|
||||
"sidebarLogsAndAnalytics": "Registri E Analisi",
|
||||
"sidebarTeam": "Squadra",
|
||||
"sidebarUsers": "Utenti",
|
||||
"sidebarAdmin": "Amministratore",
|
||||
"sidebarInvitations": "Inviti",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||
"sidebarBluePrints": "Progetti",
|
||||
"sidebarOrganization": "Organizzazione",
|
||||
"sidebarManagement": "Gestione",
|
||||
"sidebarBillingAndLicenses": "Fatturazione E Licenze",
|
||||
"sidebarLogsAnalytics": "Analisi",
|
||||
"blueprints": "Progetti",
|
||||
"blueprintsDescription": "Applica le configurazioni dichiarative e visualizza le partite precedenti",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Siti",
|
||||
"billingUsers": "Utenti",
|
||||
"billingDomains": "Domini",
|
||||
"billingOrganizations": "Organi",
|
||||
"billingRemoteExitNodes": "Nodi Remoti",
|
||||
"billingNoLimitConfigured": "Nessun limite configurato",
|
||||
"billingEstimatedPeriod": "Periodo di Fatturazione Stimato",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Fallito",
|
||||
"createNewOrgDescription": "Crea una nuova organizzazione",
|
||||
"organization": "Organizzazione",
|
||||
"primary": "Principale",
|
||||
"port": "Porta",
|
||||
"securityKeyManage": "Gestisci chiavi di sicurezza",
|
||||
"securityKeyDescription": "Aggiungi o rimuovi chiavi di sicurezza per l'autenticazione senza password",
|
||||
@@ -1624,6 +1645,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.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Sei sicuro di voler rimuovere il branding per le pagine di autenticazione?",
|
||||
"authPageBrandingDeleteConfirm": "Conferma Eliminazione Branding",
|
||||
"brandingLogoURL": "URL Logo",
|
||||
"brandingLogoURLOrPath": "URL o percorso del logo",
|
||||
"brandingLogoPathDescription": "Inserisci un URL o un percorso locale.",
|
||||
"brandingLogoURLDescription": "Inserisci un URL accessibile al pubblico per la tua immagine del logo.",
|
||||
"brandingPrimaryColor": "Colore Primario",
|
||||
"brandingLogoWidth": "Larghezza (px)",
|
||||
"brandingLogoHeight": "Altezza (px)",
|
||||
@@ -2481,6 +2523,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 <docsLink>la documentazione</docsLink> 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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "프로토콜 선택",
|
||||
"resourcePortNumber": "포트 번호",
|
||||
"resourcePortNumberDescription": "요청을 프록시하기 위한 외부 포트 번호입니다.",
|
||||
"back": "뒤로",
|
||||
"cancel": "취소",
|
||||
"resourceConfig": "구성 스니펫",
|
||||
"resourceConfigDescription": "TCP/UDP 리소스를 설정하기 위해 이 구성 스니펫을 복사하여 붙여넣습니다.",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "조직을 삭제하는 중 오류가 발생했습니다.",
|
||||
"orgDeleted": "조직이 삭제되었습니다.",
|
||||
"orgDeletedMessage": "조직과 그 데이터가 삭제되었습니다.",
|
||||
"deleteAccount": "계정 삭제",
|
||||
"deleteAccountDescription": "계정, 소유한 모든 조직 및 조직 내의 모든 데이터를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"deleteAccountButton": "계정 삭제",
|
||||
"deleteAccountConfirmTitle": "계정 삭제",
|
||||
"deleteAccountConfirmMessage": "이 작업은 귀하의 계정, 소유한 모든 조직 및 조직 내 모든 데이터를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"deleteAccountConfirmString": "계정 삭제",
|
||||
"deleteAccountSuccess": "계정 삭제됨",
|
||||
"deleteAccountSuccessMessage": "계정이 삭제되었습니다.",
|
||||
"deleteAccountError": "계정 삭제 실패",
|
||||
"deleteAccountPreviewAccount": "귀하의 계정",
|
||||
"deleteAccountPreviewOrgs": "귀하가 소유한 조직(포함된 모든 데이터)",
|
||||
"orgMissing": "조직 ID가 누락되었습니다",
|
||||
"orgMissingMessage": "조직 ID 없이 초대장을 재생성할 수 없습니다.",
|
||||
"accessUsersManage": "사용자 관리",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "승인 상태로 필터링",
|
||||
"approvalListEmpty": "승인이 없습니다.",
|
||||
"approvalState": "승인 상태",
|
||||
"approvalLoadMore": "더 불러오기",
|
||||
"loadingApprovals": "승인 불러오는 중",
|
||||
"approve": "승인",
|
||||
"approved": "승인됨",
|
||||
"denied": "거부됨",
|
||||
@@ -776,6 +790,7 @@
|
||||
"accessRoleRemoved": "역할이 제거되었습니다",
|
||||
"accessRoleRemovedDescription": "역할이 성공적으로 제거되었습니다.",
|
||||
"accessRoleRequiredRemove": "이 역할을 삭제하기 전에 기존 구성원을 전송할 새 역할을 선택하세요.",
|
||||
"network": "네트워크",
|
||||
"manage": "관리",
|
||||
"sitesNotFound": "사이트를 찾을 수 없습니다.",
|
||||
"pangolinServerAdmin": "서버 관리자 - 판골린",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "설정 - 판골린",
|
||||
"orgNameRequired": "조직 이름은 필수입니다.",
|
||||
"orgIdRequired": "조직 ID가 필요합니다",
|
||||
"orgIdMaxLength": "조직 ID는 최대 32자 이내여야 합니다",
|
||||
"orgErrorCreate": "조직 생성 중 오류가 발생했습니다.",
|
||||
"pageNotFound": "페이지를 찾을 수 없습니다",
|
||||
"pageNotFoundDescription": "앗! 찾고 있는 페이지가 존재하지 않습니다.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "로그 보기",
|
||||
"noneSelected": "선택된 항목 없음",
|
||||
"orgNotFound2": "조직이 없습니다.",
|
||||
"searchProgress": "검색...",
|
||||
"searchPlaceholder": "검색...",
|
||||
"emptySearchOptions": "옵션이 없습니다",
|
||||
"create": "생성",
|
||||
"orgs": "조직",
|
||||
"loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "비공개",
|
||||
"sidebarAccessControl": "액세스 제어",
|
||||
"sidebarLogsAndAnalytics": "로그 및 분석",
|
||||
"sidebarTeam": "팀",
|
||||
"sidebarUsers": "사용자",
|
||||
"sidebarAdmin": "관리자",
|
||||
"sidebarInvitations": "초대",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "로그 & 통계",
|
||||
"sidebarBluePrints": "청사진",
|
||||
"sidebarOrganization": "조직",
|
||||
"sidebarManagement": "관리",
|
||||
"sidebarBillingAndLicenses": "결제 및 라이선스",
|
||||
"sidebarLogsAnalytics": "분석",
|
||||
"blueprints": "청사진",
|
||||
"blueprintsDescription": "선언적 구성을 적용하고 이전 실행을 봅니다",
|
||||
@@ -1272,7 +1292,6 @@
|
||||
"parsedContents": "구문 분석된 콘텐츠 (읽기 전용)",
|
||||
"enableDockerSocket": "Docker 청사진 활성화",
|
||||
"enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 수집을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
|
||||
"enableDockerSocketLink": "자세히 알아보기",
|
||||
"viewDockerContainers": "도커 컨테이너 보기",
|
||||
"containersIn": "{siteName}의 컨테이너",
|
||||
"selectContainerDescription": "이 대상을 위한 호스트 이름으로 사용할 컨테이너를 선택하세요. 포트를 사용하려면 포트를 클릭하세요.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "사이트",
|
||||
"billingUsers": "사용자",
|
||||
"billingDomains": "도메인",
|
||||
"billingOrganizations": "조직",
|
||||
"billingRemoteExitNodes": "원격 노드",
|
||||
"billingNoLimitConfigured": "구성된 한도가 없습니다.",
|
||||
"billingEstimatedPeriod": "예상 청구 기간",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "실패",
|
||||
"createNewOrgDescription": "새 조직 생성",
|
||||
"organization": "조직",
|
||||
"primary": "기본",
|
||||
"port": "포트",
|
||||
"securityKeyManage": "보안 키 관리",
|
||||
"securityKeyDescription": "비밀번호 없는 인증을 위해 보안 키를 추가하거나 제거합니다.",
|
||||
@@ -1624,6 +1645,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이 정상으로 간주됩니다.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "인증 페이지의 브랜딩을 제거하시겠습니까?",
|
||||
"authPageBrandingDeleteConfirm": "브랜딩 삭제 확인",
|
||||
"brandingLogoURL": "로고 URL",
|
||||
"brandingLogoURLOrPath": "로고 URL 또는 경로",
|
||||
"brandingLogoPathDescription": "URL 또는 로컬 경로를 입력하세요.",
|
||||
"brandingLogoURLDescription": "로고 이미지에 대한 공용 URL을 입력하십시오.",
|
||||
"brandingPrimaryColor": "기본 색상",
|
||||
"brandingLogoWidth": "너비(px)",
|
||||
"brandingLogoHeight": "높이(px)",
|
||||
@@ -2481,6 +2523,17 @@
|
||||
"editInternalResourceDialogAccessControl": "액세스 제어",
|
||||
"editInternalResourceDialogAccessControlDescription": "연결 시 이 리소스에 대한 액세스 권한을 가지는 역할, 사용자, 그리고 머신 클라이언트를 제어합니다. 관리자는 항상 접근할 수 있습니다.",
|
||||
"editInternalResourceDialogPortRangeValidationError": "모든 포트에 대해서는 \"*\"로, 아니면 쉼표로 구분된 포트 및 범위 목록(예: \"80,443,8000-9000\")을 설정해야 합니다. 포트는 1에서 65535 사이여야 합니다.",
|
||||
"internalResourceAuthDaemonStrategy": "SSH 인증 데몬 위치",
|
||||
"internalResourceAuthDaemonStrategyDescription": "SSH 인증 데몬이 작동하는 위치를 선택하세요: 사이트(Newt)에서 또는 원격 호스트에서.",
|
||||
"internalResourceAuthDaemonDescription": "SSH 인증 데몬은 이 리소스를 위한 SSH 키 서명과 PAM 인증을 처리합니다. 사이트(Newt)에서 나 별도의 원격 호스트에서 실행할 것인지를 선택하세요. 자세한 내용은 <docsLink>문서</docsLink>를 참조하세요.",
|
||||
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
|
||||
"internalResourceAuthDaemonStrategyPlaceholder": "전략 선택",
|
||||
"internalResourceAuthDaemonStrategyLabel": "위치",
|
||||
"internalResourceAuthDaemonSite": "사이트에서 인증 데몬이 실행됩니다(Newt).",
|
||||
"internalResourceAuthDaemonSiteDescription": "인증 데몬이 사이트(Newt)에서 실행됩니다.",
|
||||
"internalResourceAuthDaemonRemote": "원격 호스트",
|
||||
"internalResourceAuthDaemonRemoteDescription": "인증 데몬이 사이트가 아닌 다른 호스트에서 실행됩니다.",
|
||||
"internalResourceAuthDaemonPort": "데몬 포트 (선택 사항)",
|
||||
"orgAuthWhatsThis": "조직 ID를 어디에서 찾을 수 있습니까?",
|
||||
"learnMore": "자세히 알아보기",
|
||||
"backToHome": "홈으로 돌아가기",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Velg en protokoll",
|
||||
"resourcePortNumber": "Portnummer",
|
||||
"resourcePortNumberDescription": "Det eksterne portnummeret for proxy forespørsler.",
|
||||
"back": "Tilbake",
|
||||
"cancel": "Avbryt",
|
||||
"resourceConfig": "Konfigurasjonsutdrag",
|
||||
"resourceConfigDescription": "Kopier og lim inn disse konfigurasjons-øyeblikkene for å sette opp TCP/UDP ressursen",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Det oppsto en feil under sletting av organisasjonen.",
|
||||
"orgDeleted": "Organisasjon slettet",
|
||||
"orgDeletedMessage": "Organisasjonen og tilhørende data er slettet.",
|
||||
"deleteAccount": "Slett konto",
|
||||
"deleteAccountDescription": "Slett kontoen din permanent, alle organisasjoner du eier, og alle data i disse organisasjonene. Dette kan ikke angres.",
|
||||
"deleteAccountButton": "Slett konto",
|
||||
"deleteAccountConfirmTitle": "Slett konto",
|
||||
"deleteAccountConfirmMessage": "Dette vil slette kontoen din, alle organisasjoner du eier og alle data i disse organisasjonene. Dette kan ikke gjøres om.",
|
||||
"deleteAccountConfirmString": "Slett konto",
|
||||
"deleteAccountSuccess": "Kontoen er slettet",
|
||||
"deleteAccountSuccessMessage": "Kontoen din er slettet.",
|
||||
"deleteAccountError": "Kunne ikke slette konto",
|
||||
"deleteAccountPreviewAccount": "Din konto",
|
||||
"deleteAccountPreviewOrgs": "Organisasjoner du eier (og alle deres data)",
|
||||
"orgMissing": "Organisasjons-ID Mangler",
|
||||
"orgMissingMessage": "Kan ikke regenerere invitasjon uten en organisasjons-ID.",
|
||||
"accessUsersManage": "Administrer brukere",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Filtrer etter godkjenningsstatus",
|
||||
"approvalListEmpty": "Ingen godkjenninger",
|
||||
"approvalState": "Godkjennings tilstand",
|
||||
"approvalLoadMore": "Last mer",
|
||||
"loadingApprovals": "Laster inn godkjenninger",
|
||||
"approve": "Godkjenn",
|
||||
"approved": "Godkjent",
|
||||
"denied": "Avvist",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Oppsett - Pangolin",
|
||||
"orgNameRequired": "Organisasjonsnavn er påkrevd",
|
||||
"orgIdRequired": "Organisasjons-ID er påkrevd",
|
||||
"orgIdMaxLength": "Organisasjons-ID må maksimalt være 32 tegn",
|
||||
"orgErrorCreate": "En feil oppstod under oppretting av organisasjon",
|
||||
"pageNotFound": "Siden ble ikke funnet",
|
||||
"pageNotFoundDescription": "Oops! Siden du leter etter finnes ikke.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Vis logger",
|
||||
"noneSelected": "Ingen valgt",
|
||||
"orgNotFound2": "Ingen organisasjoner funnet.",
|
||||
"searchProgress": "Søker...",
|
||||
"searchPlaceholder": "Søk...",
|
||||
"emptySearchOptions": "Ingen valg funnet",
|
||||
"create": "Opprett",
|
||||
"orgs": "Organisasjoner",
|
||||
"loginError": "En uventet feil oppstod. Vennligst prøv igjen.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Privat",
|
||||
"sidebarAccessControl": "Tilgangskontroll",
|
||||
"sidebarLogsAndAnalytics": "Logger og analyser",
|
||||
"sidebarTeam": "Lag",
|
||||
"sidebarUsers": "Brukere",
|
||||
"sidebarAdmin": "Administrator",
|
||||
"sidebarInvitations": "Invitasjoner",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Logg og analyser",
|
||||
"sidebarBluePrints": "Tegninger",
|
||||
"sidebarOrganization": "Organisasjon",
|
||||
"sidebarManagement": "Administrasjon",
|
||||
"sidebarBillingAndLicenses": "Fakturering & lisenser",
|
||||
"sidebarLogsAnalytics": "Analyser",
|
||||
"blueprints": "Tegninger",
|
||||
"blueprintsDescription": "Bruk deklarative konfigurasjoner og vis tidligere kjøringer",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Områder",
|
||||
"billingUsers": "Brukere",
|
||||
"billingDomains": "Domener",
|
||||
"billingOrganizations": "Orger",
|
||||
"billingRemoteExitNodes": "Eksterne Noder",
|
||||
"billingNoLimitConfigured": "Ingen grense konfigurert",
|
||||
"billingEstimatedPeriod": "Estimert faktureringsperiode",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Mislyktes",
|
||||
"createNewOrgDescription": "Opprett en ny organisasjon",
|
||||
"organization": "Organisasjon",
|
||||
"primary": "Primær",
|
||||
"port": "Port",
|
||||
"securityKeyManage": "Administrer sikkerhetsnøkler",
|
||||
"securityKeyDescription": "Legg til eller fjern sikkerhetsnøkler for passordløs autentisering",
|
||||
@@ -1624,6 +1645,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.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Er du sikker på at du vil fjerne merkevarebyggingen for autentiseringssider?",
|
||||
"authPageBrandingDeleteConfirm": "Bekreft sletting av merkevarebygging",
|
||||
"brandingLogoURL": "Logo URL",
|
||||
"brandingLogoURLOrPath": "Logoen URL eller sti",
|
||||
"brandingLogoPathDescription": "Skriv inn en URL eller en lokal bane.",
|
||||
"brandingLogoURLDescription": "Skriv inn en offentlig tilgjengelig nettadresse til din logobilde.",
|
||||
"brandingPrimaryColor": "Primærfarge",
|
||||
"brandingLogoWidth": "Bredde (px)",
|
||||
"brandingLogoHeight": "Høyde (px)",
|
||||
@@ -2481,6 +2523,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 <docsLink>dokumentasjonen</docsLink> 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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Selecteer een protocol",
|
||||
"resourcePortNumber": "Nummer van poort",
|
||||
"resourcePortNumberDescription": "Het externe poortnummer naar proxyverzoeken.",
|
||||
"back": "Achterzijde",
|
||||
"cancel": "Annuleren",
|
||||
"resourceConfig": "Configuratie tekstbouwstenen",
|
||||
"resourceConfigDescription": "Kopieer en plak deze configuratie-snippets om de TCP/UDP-bron in te stellen",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Er is een fout opgetreden tijdens het verwijderen van de organisatie.",
|
||||
"orgDeleted": "Organisatie verwijderd",
|
||||
"orgDeletedMessage": "De organisatie en haar gegevens zijn verwijderd.",
|
||||
"deleteAccount": "Verwijder account",
|
||||
"deleteAccountDescription": "Verwijdert permanent uw account, alle organisaties die u bezit, en alle gegevens binnen deze organisaties. Dit kan niet ongedaan worden gemaakt.",
|
||||
"deleteAccountButton": "Verwijder account",
|
||||
"deleteAccountConfirmTitle": "Verwijder account",
|
||||
"deleteAccountConfirmMessage": "Dit zal uw account permanent wissen, alle organisaties die u bezit, en alle gegevens binnen deze organisaties. Dit kan niet ongedaan worden gemaakt.",
|
||||
"deleteAccountConfirmString": "verwijder account",
|
||||
"deleteAccountSuccess": "Account verwijderd",
|
||||
"deleteAccountSuccessMessage": "Uw account is verwijderd.",
|
||||
"deleteAccountError": "Kan account niet verwijderen",
|
||||
"deleteAccountPreviewAccount": "Uw account",
|
||||
"deleteAccountPreviewOrgs": "Organisaties die je bezit (en al hun gegevens)",
|
||||
"orgMissing": "Organisatie-ID ontbreekt",
|
||||
"orgMissingMessage": "Niet in staat om de uitnodiging te regenereren zonder organisatie-ID.",
|
||||
"accessUsersManage": "Gebruikers beheren",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Filter op goedkeuringsstatus",
|
||||
"approvalListEmpty": "Geen goedkeuringen",
|
||||
"approvalState": "Goedkeuring status",
|
||||
"approvalLoadMore": "Meer laden",
|
||||
"loadingApprovals": "Goedkeuringen laden",
|
||||
"approve": "Goedkeuren",
|
||||
"approved": "Goedgekeurd",
|
||||
"denied": "Geweigerd",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Instellen - Pangolin",
|
||||
"orgNameRequired": "Organisatienaam is vereist",
|
||||
"orgIdRequired": "Organisatie-ID is vereist",
|
||||
"orgIdMaxLength": "Organisatie-ID mag maximaal 32 tekens lang zijn",
|
||||
"orgErrorCreate": "Fout opgetreden tijdens het aanmaken org",
|
||||
"pageNotFound": "Pagina niet gevonden",
|
||||
"pageNotFoundDescription": "Oeps! De pagina die je zoekt bestaat niet.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Logboeken bekijken",
|
||||
"noneSelected": "Niet geselecteerd",
|
||||
"orgNotFound2": "Geen organisaties gevonden.",
|
||||
"searchProgress": "Zoeken...",
|
||||
"searchPlaceholder": "Zoeken...",
|
||||
"emptySearchOptions": "Geen opties gevonden",
|
||||
"create": "Aanmaken",
|
||||
"orgs": "Organisaties",
|
||||
"loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Privé",
|
||||
"sidebarAccessControl": "Toegangs controle",
|
||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||
"sidebarTeam": "Team",
|
||||
"sidebarUsers": "Gebruikers",
|
||||
"sidebarAdmin": "Beheerder",
|
||||
"sidebarInvitations": "Uitnodigingen",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||
"sidebarBluePrints": "Blauwdrukken",
|
||||
"sidebarOrganization": "Organisatie",
|
||||
"sidebarManagement": "Beheer",
|
||||
"sidebarBillingAndLicenses": "Facturatie & Licenties",
|
||||
"sidebarLogsAnalytics": "Analyses",
|
||||
"blueprints": "Blauwdrukken",
|
||||
"blueprintsDescription": "Gebruik declaratieve configuraties en bekijk vorige uitvoeringen.",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Sites",
|
||||
"billingUsers": "Gebruikers",
|
||||
"billingDomains": "Domeinen",
|
||||
"billingOrganizations": "Ordenen",
|
||||
"billingRemoteExitNodes": "Externe knooppunten",
|
||||
"billingNoLimitConfigured": "Geen limiet ingesteld",
|
||||
"billingEstimatedPeriod": "Geschatte Facturatie Periode",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Mislukt",
|
||||
"createNewOrgDescription": "Maak een nieuwe organisatie",
|
||||
"organization": "Organisatie",
|
||||
"primary": "Primair",
|
||||
"port": "Poort",
|
||||
"securityKeyManage": "Beveiligingssleutels beheren",
|
||||
"securityKeyDescription": "Voeg beveiligingssleutels toe of verwijder ze voor wachtwoordloze authenticatie",
|
||||
@@ -1624,6 +1645,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.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Weet u zeker dat u de branding voor Auth-pagina's wilt verwijderen?",
|
||||
"authPageBrandingDeleteConfirm": "Bevestig verwijder Branding",
|
||||
"brandingLogoURL": "Het logo-URL",
|
||||
"brandingLogoURLOrPath": "Logo URL of pad",
|
||||
"brandingLogoPathDescription": "Voer een URL of een lokaal pad in.",
|
||||
"brandingLogoURLDescription": "Voer een openbaar toegankelijke URL in voor uw logo afbeelding.",
|
||||
"brandingPrimaryColor": "Primaire kleur",
|
||||
"brandingLogoWidth": "Breedte (px)",
|
||||
"brandingLogoHeight": "Hoogte (px)",
|
||||
@@ -2481,6 +2523,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 <docsLink>de documentatie</docsLink> 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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Wybierz protokół",
|
||||
"resourcePortNumber": "Numer portu",
|
||||
"resourcePortNumberDescription": "Numer portu zewnętrznego do żądań proxy.",
|
||||
"back": "Powrót",
|
||||
"cancel": "Anuluj",
|
||||
"resourceConfig": "Snippety konfiguracji",
|
||||
"resourceConfigDescription": "Skopiuj i wklej te fragmenty konfiguracji, aby skonfigurować zasób TCP/UDP",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Wystąpił błąd podczas usuwania organizacji.",
|
||||
"orgDeleted": "Organizacja usunięta",
|
||||
"orgDeletedMessage": "Organizacja i jej dane zostały usunięte.",
|
||||
"deleteAccount": "Usuń konto",
|
||||
"deleteAccountDescription": "Trwale usuń swoje konto, wszystkie organizacje, które posiadasz, oraz wszystkie dane w ramach tych organizacji. Tej operacji nie można cofnąć.",
|
||||
"deleteAccountButton": "Usuń konto",
|
||||
"deleteAccountConfirmTitle": "Usuń konto",
|
||||
"deleteAccountConfirmMessage": "Spowoduje to trwałe usunięcie konta, wszystkich organizacji, które posiadasz, oraz wszystkich danych w tych organizacjach. Tej operacji nie można cofnąć.",
|
||||
"deleteAccountConfirmString": "usuń konto",
|
||||
"deleteAccountSuccess": "Konto usunięte",
|
||||
"deleteAccountSuccessMessage": "Twoje konto zostało usunięte.",
|
||||
"deleteAccountError": "Nie udało się usunąć konta",
|
||||
"deleteAccountPreviewAccount": "Twoje konto",
|
||||
"deleteAccountPreviewOrgs": "Organizacje, które jesteś właścicielem (i wszystkie ich dane)",
|
||||
"orgMissing": "Brak ID organizacji",
|
||||
"orgMissingMessage": "Nie można ponownie wygenerować zaproszenia bez ID organizacji.",
|
||||
"accessUsersManage": "Zarządzaj użytkownikami",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Filtruj według państwa zatwierdzenia",
|
||||
"approvalListEmpty": "Brak zatwierdzeń",
|
||||
"approvalState": "Państwo zatwierdzające",
|
||||
"approvalLoadMore": "Załaduj więcej",
|
||||
"loadingApprovals": "Wczytywanie zatwierdzeń",
|
||||
"approve": "Zatwierdź",
|
||||
"approved": "Zatwierdzone",
|
||||
"denied": "Odmowa",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Konfiguracja - Pangolin",
|
||||
"orgNameRequired": "Nazwa organizacji jest wymagana",
|
||||
"orgIdRequired": "ID organizacji jest wymagane",
|
||||
"orgIdMaxLength": "Identyfikator organizacji musi mieć co najwyżej 32 znaki",
|
||||
"orgErrorCreate": "Wystąpił błąd podczas tworzenia organizacji",
|
||||
"pageNotFound": "Nie znaleziono strony",
|
||||
"pageNotFoundDescription": "Ups! Strona, której szukasz, nie istnieje.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Zobacz dzienniki",
|
||||
"noneSelected": "Nie wybrano",
|
||||
"orgNotFound2": "Nie znaleziono organizacji.",
|
||||
"searchProgress": "Szukaj...",
|
||||
"searchPlaceholder": "Szukaj...",
|
||||
"emptySearchOptions": "Nie znaleziono opcji",
|
||||
"create": "Utwórz",
|
||||
"orgs": "Organizacje",
|
||||
"loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Prywatny",
|
||||
"sidebarAccessControl": "Kontrola dostępu",
|
||||
"sidebarLogsAndAnalytics": "Logi i Analityki",
|
||||
"sidebarTeam": "Drużyna",
|
||||
"sidebarUsers": "Użytkownicy",
|
||||
"sidebarAdmin": "Administrator",
|
||||
"sidebarInvitations": "Zaproszenia",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Dziennik & Analityka",
|
||||
"sidebarBluePrints": "Schematy",
|
||||
"sidebarOrganization": "Organizacja",
|
||||
"sidebarManagement": "Zarządzanie",
|
||||
"sidebarBillingAndLicenses": "Płatność i licencje",
|
||||
"sidebarLogsAnalytics": "Analityka",
|
||||
"blueprints": "Schematy",
|
||||
"blueprintsDescription": "Zastosuj konfiguracje deklaracyjne i wyświetl poprzednie operacje",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Witryny",
|
||||
"billingUsers": "Użytkownicy",
|
||||
"billingDomains": "Domeny",
|
||||
"billingOrganizations": "O masie całkowitej pojazdu przekraczającej 5 ton, ale nieprzekraczającej 5 ton",
|
||||
"billingRemoteExitNodes": "Zdalne węzły",
|
||||
"billingNoLimitConfigured": "Nie skonfigurowano limitu",
|
||||
"billingEstimatedPeriod": "Szacowany Okres Rozliczeniowy",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Niepowodzenie",
|
||||
"createNewOrgDescription": "Utwórz nową organizację",
|
||||
"organization": "Organizacja",
|
||||
"primary": "Podstawowy",
|
||||
"port": "Port",
|
||||
"securityKeyManage": "Zarządzaj kluczami bezpieczeństwa",
|
||||
"securityKeyDescription": "Dodaj lub usuń klucze bezpieczeństwa do uwierzytelniania bez hasła",
|
||||
@@ -1624,6 +1645,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.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Czy na pewno chcesz usunąć branding dla stron uwierzytelniania?",
|
||||
"authPageBrandingDeleteConfirm": "Potwierdź usunięcie brandingu",
|
||||
"brandingLogoURL": "URL logo",
|
||||
"brandingLogoURLOrPath": "Adres URL logo lub ścieżka",
|
||||
"brandingLogoPathDescription": "Wprowadź adres URL lub ścieżkę lokalną.",
|
||||
"brandingLogoURLDescription": "Wprowadź publicznie dostępny adres URL do obrazu logo.",
|
||||
"brandingPrimaryColor": "Główny kolor",
|
||||
"brandingLogoWidth": "Szerokość (piksele)",
|
||||
"brandingLogoHeight": "Wysokość (piksele)",
|
||||
@@ -2481,6 +2523,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 <docsLink>dokumentację</docsLink> 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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Selecione um protocolo",
|
||||
"resourcePortNumber": "Número da Porta",
|
||||
"resourcePortNumberDescription": "O número da porta externa para requisições de proxy.",
|
||||
"back": "Anterior",
|
||||
"cancel": "cancelar",
|
||||
"resourceConfig": "Snippets de Configuração",
|
||||
"resourceConfigDescription": "Copie e cole estes snippets de configuração para configurar o recurso TCP/UDP",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Ocorreu um erro ao apagar a organização.",
|
||||
"orgDeleted": "Organização excluída",
|
||||
"orgDeletedMessage": "A organização e seus dados foram excluídos.",
|
||||
"deleteAccount": "Excluir Conta",
|
||||
"deleteAccountDescription": "Exclua permanentemente sua conta, todas as organizações que você possui e todos os dados nessas organizações. Isso não pode ser desfeito.",
|
||||
"deleteAccountButton": "Excluir Conta",
|
||||
"deleteAccountConfirmTitle": "Excluir Conta",
|
||||
"deleteAccountConfirmMessage": "Isto limpará permanentemente sua conta, todas as organizações que você possui e todos os dados dentro dessas organizações. Isso não pode ser desfeito.",
|
||||
"deleteAccountConfirmString": "excluir conta",
|
||||
"deleteAccountSuccess": "Conta excluída",
|
||||
"deleteAccountSuccessMessage": "Sua conta foi excluída.",
|
||||
"deleteAccountError": "Falha ao excluir conta",
|
||||
"deleteAccountPreviewAccount": "Sua conta",
|
||||
"deleteAccountPreviewOrgs": "Organizações que você possui (e todos os dados deles)",
|
||||
"orgMissing": "ID da Organização Ausente",
|
||||
"orgMissingMessage": "Não é possível regenerar o convite sem um ID de organização.",
|
||||
"accessUsersManage": "Gerir Utilizadores",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Filtrar por estado de aprovação",
|
||||
"approvalListEmpty": "Sem aprovações",
|
||||
"approvalState": "Estado de aprovação",
|
||||
"approvalLoadMore": "Carregue mais",
|
||||
"loadingApprovals": "Carregando aprovações",
|
||||
"approve": "Aprovar",
|
||||
"approved": "Aceito",
|
||||
"denied": "Negado",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Configuração - Pangolin",
|
||||
"orgNameRequired": "O nome da organização é obrigatório",
|
||||
"orgIdRequired": "O ID da organização é obrigatório",
|
||||
"orgIdMaxLength": "ID da organização deve ter no máximo 32 caracteres",
|
||||
"orgErrorCreate": "Ocorreu um erro ao criar a organização",
|
||||
"pageNotFound": "Página Não Encontrada",
|
||||
"pageNotFoundDescription": "Ops! A página que você está procurando não existe.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Visualizar registros",
|
||||
"noneSelected": "Nenhum selecionado",
|
||||
"orgNotFound2": "Nenhuma organização encontrada.",
|
||||
"searchProgress": "Pesquisar...",
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"emptySearchOptions": "Nenhuma opção encontrada",
|
||||
"create": "Criar",
|
||||
"orgs": "Organizações",
|
||||
"loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Privado",
|
||||
"sidebarAccessControl": "Controle de Acesso",
|
||||
"sidebarLogsAndAnalytics": "Registros e Análises",
|
||||
"sidebarTeam": "Equipe",
|
||||
"sidebarUsers": "Utilizadores",
|
||||
"sidebarAdmin": "Administrador",
|
||||
"sidebarInvitations": "Convites",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Registo & Análise",
|
||||
"sidebarBluePrints": "Diagramas",
|
||||
"sidebarOrganization": "Organização",
|
||||
"sidebarManagement": "Gestão",
|
||||
"sidebarBillingAndLicenses": "Faturamento e Licenças",
|
||||
"sidebarLogsAnalytics": "Análises",
|
||||
"blueprints": "Diagramas",
|
||||
"blueprintsDescription": "Aplicar configurações declarativas e ver execuções anteriores",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "sites",
|
||||
"billingUsers": "Utilizadores",
|
||||
"billingDomains": "Domínios",
|
||||
"billingOrganizations": "Órgãos",
|
||||
"billingRemoteExitNodes": "Nós remotos",
|
||||
"billingNoLimitConfigured": "Nenhum limite configurado",
|
||||
"billingEstimatedPeriod": "Período Estimado de Cobrança",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Falhou",
|
||||
"createNewOrgDescription": "Crie uma nova organização",
|
||||
"organization": "Organização",
|
||||
"primary": "Primário",
|
||||
"port": "Porta",
|
||||
"securityKeyManage": "Gerir chaves de segurança",
|
||||
"securityKeyDescription": "Adicionar ou remover chaves de segurança para autenticação sem senha",
|
||||
@@ -1624,6 +1645,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.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Tem certeza de que deseja remover a marcação das Páginas de Autenticação?",
|
||||
"authPageBrandingDeleteConfirm": "Confirmar Exclusão de Marca",
|
||||
"brandingLogoURL": "URL do Logo",
|
||||
"brandingLogoURLOrPath": "URL ou caminho do logotipo",
|
||||
"brandingLogoPathDescription": "Insira uma URL ou um caminho local.",
|
||||
"brandingLogoURLDescription": "Digite uma URL publicamente acessível para a sua imagem do logotipo.",
|
||||
"brandingPrimaryColor": "Cor Primária",
|
||||
"brandingLogoWidth": "Largura (px)",
|
||||
"brandingLogoHeight": "Altura (px)",
|
||||
@@ -2481,6 +2523,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 <docsLink>a documentação</docsLink> 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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Выберите протокол",
|
||||
"resourcePortNumber": "Номер порта",
|
||||
"resourcePortNumberDescription": "Внешний номер порта для проксирования запросов.",
|
||||
"back": "Назад",
|
||||
"cancel": "Отмена",
|
||||
"resourceConfig": "Фрагменты конфигурации",
|
||||
"resourceConfigDescription": "Скопируйте и вставьте эти сниппеты для настройки TCP/UDP ресурса",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Произошла ошибка при удалении организации.",
|
||||
"orgDeleted": "Организация удалена",
|
||||
"orgDeletedMessage": "Организация и её данные были удалены.",
|
||||
"deleteAccount": "Удалить аккаунт",
|
||||
"deleteAccountDescription": "Окончательно удалить учетную запись, все организации, которые вы владеете, и все данные этих организаций не могут быть отменены.",
|
||||
"deleteAccountButton": "Удалить аккаунт",
|
||||
"deleteAccountConfirmTitle": "Удалить аккаунт",
|
||||
"deleteAccountConfirmMessage": "Это очистит ваш аккаунт, все организации, которым вы владеете, и все данные этих организаций не могут быть отменены.",
|
||||
"deleteAccountConfirmString": "удалить аккаунт",
|
||||
"deleteAccountSuccess": "Учетная запись удалена",
|
||||
"deleteAccountSuccessMessage": "Ваша учетная запись удалена.",
|
||||
"deleteAccountError": "Не удалось удалить аккаунт",
|
||||
"deleteAccountPreviewAccount": "Ваша учетная запись",
|
||||
"deleteAccountPreviewOrgs": "Организации, которые вы владеете (и все их данные)",
|
||||
"orgMissing": "Отсутствует ID организации",
|
||||
"orgMissingMessage": "Невозможно восстановить приглашение без ID организации.",
|
||||
"accessUsersManage": "Управление пользователями",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Фильтр по состоянию утверждения",
|
||||
"approvalListEmpty": "Нет утверждений",
|
||||
"approvalState": "Состояние одобрения",
|
||||
"approvalLoadMore": "Загрузить еще",
|
||||
"loadingApprovals": "Загрузка утверждений",
|
||||
"approve": "Одобрить",
|
||||
"approved": "Одобрено",
|
||||
"denied": "Отказано",
|
||||
@@ -776,6 +790,7 @@
|
||||
"accessRoleRemoved": "Роль удалена",
|
||||
"accessRoleRemovedDescription": "Роль была успешно удалена.",
|
||||
"accessRoleRequiredRemove": "Перед удалением этой роли выберите новую роль для переноса существующих участников.",
|
||||
"network": "Сеть",
|
||||
"manage": "Управление",
|
||||
"sitesNotFound": "Сайты не найдены.",
|
||||
"pangolinServerAdmin": "Администратор сервера - Pangolin",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Настройка - Pangolin",
|
||||
"orgNameRequired": "Название организации обязательно",
|
||||
"orgIdRequired": "ID организации обязателен",
|
||||
"orgIdMaxLength": "ID организации должен быть не более 32 символов",
|
||||
"orgErrorCreate": "Произошла ошибка при создании организации",
|
||||
"pageNotFound": "Страница не найдена",
|
||||
"pageNotFoundDescription": "Упс! Страница, которую вы ищете, не существует.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Просмотр журналов",
|
||||
"noneSelected": "Ничего не выбрано",
|
||||
"orgNotFound2": "Организации не найдены.",
|
||||
"searchProgress": "Поиск...",
|
||||
"searchPlaceholder": "Поиск...",
|
||||
"emptySearchOptions": "Опции не найдены",
|
||||
"create": "Создать",
|
||||
"orgs": "Организации",
|
||||
"loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Приватный",
|
||||
"sidebarAccessControl": "Контроль доступа",
|
||||
"sidebarLogsAndAnalytics": "Журналы и аналитика",
|
||||
"sidebarTeam": "Команда",
|
||||
"sidebarUsers": "Пользователи",
|
||||
"sidebarAdmin": "Админ",
|
||||
"sidebarInvitations": "Приглашения",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Журнал и аналитика",
|
||||
"sidebarBluePrints": "Чертежи",
|
||||
"sidebarOrganization": "Организация",
|
||||
"sidebarManagement": "Управление",
|
||||
"sidebarBillingAndLicenses": "Биллинг и лицензии",
|
||||
"sidebarLogsAnalytics": "Статистика",
|
||||
"blueprints": "Чертежи",
|
||||
"blueprintsDescription": "Применить декларирующие конфигурации и просмотреть предыдущие запуски",
|
||||
@@ -1272,7 +1292,6 @@
|
||||
"parsedContents": "Переработанное содержимое (только для чтения)",
|
||||
"enableDockerSocket": "Включить чертёж Docker",
|
||||
"enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.",
|
||||
"enableDockerSocketLink": "Узнать больше",
|
||||
"viewDockerContainers": "Просмотр контейнеров Docker",
|
||||
"containersIn": "Контейнеры в {siteName}",
|
||||
"selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Сайты",
|
||||
"billingUsers": "Пользователи",
|
||||
"billingDomains": "Домены",
|
||||
"billingOrganizations": "Орги",
|
||||
"billingRemoteExitNodes": "Удаленные узлы",
|
||||
"billingNoLimitConfigured": "Лимит не установлен",
|
||||
"billingEstimatedPeriod": "Предполагаемый период выставления счетов",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Ошибка",
|
||||
"createNewOrgDescription": "Создать новую организацию",
|
||||
"organization": "Организация",
|
||||
"primary": "Первичный",
|
||||
"port": "Порт",
|
||||
"securityKeyManage": "Управление ключами безопасности",
|
||||
"securityKeyDescription": "Добавить или удалить ключи безопасности для аутентификации без пароля",
|
||||
@@ -1624,6 +1645,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 считается здоровым.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Вы уверены, что хотите удалить брендирование для страниц аутентификации?",
|
||||
"authPageBrandingDeleteConfirm": "Подтвердить удаление брендирования",
|
||||
"brandingLogoURL": "URL логотипа",
|
||||
"brandingLogoURLOrPath": "URL логотипа или путь",
|
||||
"brandingLogoPathDescription": "Введите URL или локальный путь.",
|
||||
"brandingLogoURLDescription": "Введите публичный URL для изображения вашего логотипа.",
|
||||
"brandingPrimaryColor": "Основной цвет",
|
||||
"brandingLogoWidth": "Ширина (px)",
|
||||
"brandingLogoHeight": "Высота (px)",
|
||||
@@ -2481,6 +2523,17 @@
|
||||
"editInternalResourceDialogAccessControl": "Контроль доступа",
|
||||
"editInternalResourceDialogAccessControlDescription": "Контролируйте, какие роли, пользователи и машинные клиенты имеют доступ к этому ресурсу при подключении. Администраторы всегда имеют доступ.",
|
||||
"editInternalResourceDialogPortRangeValidationError": "Диапазон портов должен быть \"*\" для всех портов или списком портов и диапазонов через запятую (например, \"80,443,8000-9000\"). Порты должны находиться в диапазоне от 1 до 65535.",
|
||||
"internalResourceAuthDaemonStrategy": "Местоположение демона по SSH",
|
||||
"internalResourceAuthDaemonStrategyDescription": "Выберите, где работает демон аутентификации SSH: на сайте (Newt) или на удаленном узле.",
|
||||
"internalResourceAuthDaemonDescription": "Демон аутентификации SSH обрабатывает подписание ключей SSH и аутентификацию PAM для этого ресурса. Выберите, запускать ли его на сайте (Newt) или на отдельном удаленном хосте. Подробности смотрите в <docsLink>документации</docsLink>.",
|
||||
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
|
||||
"internalResourceAuthDaemonStrategyPlaceholder": "Выберите стратегию",
|
||||
"internalResourceAuthDaemonStrategyLabel": "Местоположение",
|
||||
"internalResourceAuthDaemonSite": "На сайте",
|
||||
"internalResourceAuthDaemonSiteDescription": "На сайте работает демон Auth (Newt).",
|
||||
"internalResourceAuthDaemonRemote": "Удаленный хост",
|
||||
"internalResourceAuthDaemonRemoteDescription": "Демон Auth запускается на хост, который не является сайтом.",
|
||||
"internalResourceAuthDaemonPort": "Порт демона (опционально)",
|
||||
"orgAuthWhatsThis": "Где я могу найти ID моей организации?",
|
||||
"learnMore": "Узнать больше",
|
||||
"backToHome": "Вернуться домой",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "Bir protokol seçin",
|
||||
"resourcePortNumber": "Port Numarası",
|
||||
"resourcePortNumberDescription": "Vekil istekler için harici port numarası.",
|
||||
"back": "Geri",
|
||||
"cancel": "İptal",
|
||||
"resourceConfig": "Yapılandırma Parçaları",
|
||||
"resourceConfigDescription": "TCP/UDP kaynağınızı kurmak için bu yapılandırma parçalarını kopyalayıp yapıştırın",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "Organizasyon silinirken bir hata oluştu.",
|
||||
"orgDeleted": "Organizasyon silindi",
|
||||
"orgDeletedMessage": "Organizasyon ve verileri silindi.",
|
||||
"deleteAccount": "Hesabı Sil",
|
||||
"deleteAccountDescription": "Hesabınızı, sahip olduğunuz tüm organizasyonları ve bu organizasyonlardaki tüm verileri kalıcı olarak silin. Bu geri alınamaz.",
|
||||
"deleteAccountButton": "Hesabı Sil",
|
||||
"deleteAccountConfirmTitle": "Hesabı Sil",
|
||||
"deleteAccountConfirmMessage": "Bu işlem, hesabınızı, sahip olduğunuz tüm organizasyonları ve bu organizasyonlardaki tüm verileri kalıcı olarak silecektir. Bu geri alınamaz.",
|
||||
"deleteAccountConfirmString": "hesabı sil",
|
||||
"deleteAccountSuccess": "Hesap Silindi",
|
||||
"deleteAccountSuccessMessage": "Hesabınız silindi.",
|
||||
"deleteAccountError": "Hesabı silme başarısız oldu",
|
||||
"deleteAccountPreviewAccount": "Hesabınız",
|
||||
"deleteAccountPreviewOrgs": "Sahip olduğunuz organizasyonlar (ve tüm verileri)",
|
||||
"orgMissing": "Organizasyon Kimliği Eksik",
|
||||
"orgMissingMessage": "Organizasyon kimliği olmadan daveti yeniden oluşturmanız mümkün değildir.",
|
||||
"accessUsersManage": "Kullanıcıları Yönet",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "Onay Durumuna Göre Filtrele",
|
||||
"approvalListEmpty": "Onay yok",
|
||||
"approvalState": "Onay Durumu",
|
||||
"approvalLoadMore": "Daha fazla yükle",
|
||||
"loadingApprovals": "Onaylar Yükleniyor",
|
||||
"approve": "Onayla",
|
||||
"approved": "Onaylandı",
|
||||
"denied": "Reddedildi",
|
||||
@@ -776,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",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "Kurulum - Pangolin",
|
||||
"orgNameRequired": "Kuruluş adı gereklidir",
|
||||
"orgIdRequired": "Kuruluş ID gereklidir",
|
||||
"orgIdMaxLength": "Organizasyon kimliği en fazla 32 karakter olmalıdır",
|
||||
"orgErrorCreate": "Kuruluş oluşturulurken bir hata oluştu",
|
||||
"pageNotFound": "Sayfa Bulunamadı",
|
||||
"pageNotFoundDescription": "Oops! Aradığınız sayfa mevcut değil.",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "Kayıtları Görüntüle",
|
||||
"noneSelected": "Hiçbiri seçili değil",
|
||||
"orgNotFound2": "Hiçbir organizasyon bulunamadı.",
|
||||
"searchProgress": "Ara...",
|
||||
"searchPlaceholder": "Ara...",
|
||||
"emptySearchOptions": "Seçenek bulunamadı",
|
||||
"create": "Oluştur",
|
||||
"orgs": "Organizasyonlar",
|
||||
"loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "Özel",
|
||||
"sidebarAccessControl": "Erişim Kontrolü",
|
||||
"sidebarLogsAndAnalytics": "Kayıtlar & Analitik",
|
||||
"sidebarTeam": "Ekip",
|
||||
"sidebarUsers": "Kullanıcılar",
|
||||
"sidebarAdmin": "Yönetici",
|
||||
"sidebarInvitations": "Davetiye",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "Kayıt & Analiz",
|
||||
"sidebarBluePrints": "Planlar",
|
||||
"sidebarOrganization": "Organizasyon",
|
||||
"sidebarManagement": "Yönetim",
|
||||
"sidebarBillingAndLicenses": "Faturalandırma & Lisanslar",
|
||||
"sidebarLogsAnalytics": "Analitik",
|
||||
"blueprints": "Planlar",
|
||||
"blueprintsDescription": "Deklaratif yapılandırmaları uygulayın ve önceki çalışmaları görüntüleyin",
|
||||
@@ -1272,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.",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "Siteler",
|
||||
"billingUsers": "Kullanıcılar",
|
||||
"billingDomains": "Alan Adları",
|
||||
"billingOrganizations": "Organizasyonlar",
|
||||
"billingRemoteExitNodes": "Uzak Düğümler",
|
||||
"billingNoLimitConfigured": "Hiçbir limit yapılandırılmadı",
|
||||
"billingEstimatedPeriod": "Tahmini Fatura Dönemi",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "Başarısız",
|
||||
"createNewOrgDescription": "Yeni bir organizasyon oluşturun",
|
||||
"organization": "Kuruluş",
|
||||
"primary": "Birincil",
|
||||
"port": "Bağlantı Noktası",
|
||||
"securityKeyManage": "Güvenlik Anahtarlarını Yönet",
|
||||
"securityKeyDescription": "Şifresiz kimlik doğrulama için güvenlik anahtarları ekleyin veya kaldırın",
|
||||
@@ -1624,6 +1645,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.",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "Kimlik Sayfaları için markayı kaldırmak istediğinizden emin misiniz?",
|
||||
"authPageBrandingDeleteConfirm": "Markayı Silmeyi Onayla",
|
||||
"brandingLogoURL": "Logo URL",
|
||||
"brandingLogoURLOrPath": "Logo URL veya Yol",
|
||||
"brandingLogoPathDescription": "Bir URL veya yerel bir yol girin.",
|
||||
"brandingLogoURLDescription": "Logo resminiz için genel olarak erişilebilir bir URL girin.",
|
||||
"brandingPrimaryColor": "Ana Renk",
|
||||
"brandingLogoWidth": "Genişlik (px)",
|
||||
"brandingLogoHeight": "Yükseklik (px)",
|
||||
@@ -2481,6 +2523,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 <docsLink> belgeleri</docsLink> 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",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"protocolSelect": "选择协议",
|
||||
"resourcePortNumber": "端口号",
|
||||
"resourcePortNumberDescription": "代理请求的外部端口号。",
|
||||
"back": "后退",
|
||||
"cancel": "取消",
|
||||
"resourceConfig": "配置片段",
|
||||
"resourceConfigDescription": "复制并粘贴这些配置片段以设置 TCP/UDP 资源",
|
||||
@@ -246,6 +247,17 @@
|
||||
"orgErrorDeleteMessage": "删除组织时出错。",
|
||||
"orgDeleted": "组织已删除",
|
||||
"orgDeletedMessage": "组织及其数据已被删除。",
|
||||
"deleteAccount": "删除帐户",
|
||||
"deleteAccountDescription": "永久删除您的帐户、您拥有的所有组织以及这些组织中的所有数据。此操作无法撤消。",
|
||||
"deleteAccountButton": "删除帐户",
|
||||
"deleteAccountConfirmTitle": "删除帐户",
|
||||
"deleteAccountConfirmMessage": "这将永久擦除您的帐户、您拥有的所有组织以及这些组织中的所有数据。这不能撤消。",
|
||||
"deleteAccountConfirmString": "删除帐户",
|
||||
"deleteAccountSuccess": "账户已删除",
|
||||
"deleteAccountSuccessMessage": "您的帐户已被删除。",
|
||||
"deleteAccountError": "删除帐户失败",
|
||||
"deleteAccountPreviewAccount": "您的帐户",
|
||||
"deleteAccountPreviewOrgs": "您拥有的组织 (和所有数据)",
|
||||
"orgMissing": "缺少组织 ID",
|
||||
"orgMissingMessage": "没有组织ID,无法重新生成邀请。",
|
||||
"accessUsersManage": "管理用户",
|
||||
@@ -461,6 +473,8 @@
|
||||
"filterByApprovalState": "按批准状态过滤",
|
||||
"approvalListEmpty": "无批准",
|
||||
"approvalState": "审批状态",
|
||||
"approvalLoadMore": "加载更多",
|
||||
"loadingApprovals": "正在加载批准",
|
||||
"approve": "批准",
|
||||
"approved": "已批准",
|
||||
"denied": "被拒绝",
|
||||
@@ -776,6 +790,7 @@
|
||||
"accessRoleRemoved": "角色已删除",
|
||||
"accessRoleRemovedDescription": "角色已成功删除。",
|
||||
"accessRoleRequiredRemove": "删除此角色之前,请选择一个新角色来转移现有成员。",
|
||||
"network": "网络",
|
||||
"manage": "管理",
|
||||
"sitesNotFound": "未找到站点。",
|
||||
"pangolinServerAdmin": "服务器管理员 - Pangolin",
|
||||
@@ -1017,6 +1032,7 @@
|
||||
"pangolinSetup": "认证 - Pangolin",
|
||||
"orgNameRequired": "组织名称是必需的",
|
||||
"orgIdRequired": "组织ID是必需的",
|
||||
"orgIdMaxLength": "组织 ID 必须至少 32 个字符",
|
||||
"orgErrorCreate": "创建组织时出错",
|
||||
"pageNotFound": "找不到页面",
|
||||
"pageNotFoundDescription": "哎呀!您正在查找的页面不存在。",
|
||||
@@ -1169,7 +1185,8 @@
|
||||
"actionViewLogs": "查看日志",
|
||||
"noneSelected": "未选择",
|
||||
"orgNotFound2": "未找到组织。",
|
||||
"searchProgress": "搜索中...",
|
||||
"searchPlaceholder": "搜索...",
|
||||
"emptySearchOptions": "未找到选项",
|
||||
"create": "创建",
|
||||
"orgs": "组织",
|
||||
"loginError": "发生意外错误。请重试。",
|
||||
@@ -1233,6 +1250,7 @@
|
||||
"sidebarClientResources": "非公开的",
|
||||
"sidebarAccessControl": "访问控制",
|
||||
"sidebarLogsAndAnalytics": "日志与分析",
|
||||
"sidebarTeam": "团队",
|
||||
"sidebarUsers": "用户",
|
||||
"sidebarAdmin": "管理员",
|
||||
"sidebarInvitations": "邀请",
|
||||
@@ -1251,6 +1269,8 @@
|
||||
"sidebarLogAndAnalytics": "日志与分析",
|
||||
"sidebarBluePrints": "蓝图",
|
||||
"sidebarOrganization": "组织",
|
||||
"sidebarManagement": "管理",
|
||||
"sidebarBillingAndLicenses": "帐单和许可证",
|
||||
"sidebarLogsAnalytics": "分析",
|
||||
"blueprints": "蓝图",
|
||||
"blueprintsDescription": "应用声明配置并查看先前运行的",
|
||||
@@ -1272,7 +1292,6 @@
|
||||
"parsedContents": "解析内容 (只读)",
|
||||
"enableDockerSocket": "启用 Docker 蓝图",
|
||||
"enableDockerSocketDescription": "启用 Docker Socket 标签擦除蓝图标签。套接字路径必须提供给新的。",
|
||||
"enableDockerSocketLink": "了解更多",
|
||||
"viewDockerContainers": "查看停靠容器",
|
||||
"containersIn": "{siteName} 中的容器",
|
||||
"selectContainerDescription": "选择任何容器作为目标的主机名。点击端口使用端口。",
|
||||
@@ -1412,6 +1431,7 @@
|
||||
"billingSites": "站点",
|
||||
"billingUsers": "用户",
|
||||
"billingDomains": "域",
|
||||
"billingOrganizations": "球队",
|
||||
"billingRemoteExitNodes": "远程节点",
|
||||
"billingNoLimitConfigured": "未配置限制",
|
||||
"billingEstimatedPeriod": "估计结算周期",
|
||||
@@ -1454,6 +1474,7 @@
|
||||
"failed": "失败",
|
||||
"createNewOrgDescription": "创建一个新组织",
|
||||
"organization": "组织",
|
||||
"primary": "主要的",
|
||||
"port": "端口",
|
||||
"securityKeyManage": "管理安全密钥",
|
||||
"securityKeyDescription": "添加或删除用于无密码认证的安全密钥",
|
||||
@@ -1624,6 +1645,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 被视为健康。",
|
||||
@@ -1916,6 +1955,9 @@
|
||||
"authPageBrandingQuestionRemove": "您确定要移除授权页面的品牌吗?",
|
||||
"authPageBrandingDeleteConfirm": "确认删除品牌",
|
||||
"brandingLogoURL": "Logo URL",
|
||||
"brandingLogoURLOrPath": "徽标URL或路径",
|
||||
"brandingLogoPathDescription": "输入网址或本地路径。",
|
||||
"brandingLogoURLDescription": "请在您的徽标图片中输入一个可公开访问的 URL。",
|
||||
"brandingPrimaryColor": "主要颜色",
|
||||
"brandingLogoWidth": "宽度(px)",
|
||||
"brandingLogoHeight": "高度(px)",
|
||||
@@ -2481,6 +2523,17 @@
|
||||
"editInternalResourceDialogAccessControl": "访问控制",
|
||||
"editInternalResourceDialogAccessControlDescription": "控制当连接到此资源时,哪些角色、用户和机器客户端可以访问。管理员始终具有访问权。",
|
||||
"editInternalResourceDialogPortRangeValidationError": "端口范围必须为\"*\"表示所有端口,或一个用逗号分隔的端口和范围列表(例如:\"80,443,8000-9000\")。端口必须在1到65535之间。",
|
||||
"internalResourceAuthDaemonStrategy": "SSH 认证守护进程位置",
|
||||
"internalResourceAuthDaemonStrategyDescription": "选择 SSH 身份验证守护进程在哪里运行:站点(新建) 或远程主机。",
|
||||
"internalResourceAuthDaemonDescription": "SSH 身份验证守护程序处理此资源的 SSH 密钥签名和PAM 身份验证。 选择它是在站点(新建)还是在单独的远程主机上运行。请参阅 <docsLink>文档</docsLink>。",
|
||||
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
|
||||
"internalResourceAuthDaemonStrategyPlaceholder": "选择策略",
|
||||
"internalResourceAuthDaemonStrategyLabel": "地点",
|
||||
"internalResourceAuthDaemonSite": "在站点",
|
||||
"internalResourceAuthDaemonSiteDescription": "认证守护进程在站点上运行(新建)。",
|
||||
"internalResourceAuthDaemonRemote": "远程主机",
|
||||
"internalResourceAuthDaemonRemoteDescription": "认证守护进程运行在不是站点的主机上。",
|
||||
"internalResourceAuthDaemonPort": "守护进程端口(可选)",
|
||||
"orgAuthWhatsThis": "我的组织ID在哪里可以找到?",
|
||||
"learnMore": "了解更多",
|
||||
"backToHome": "返回首页",
|
||||
|
||||
4127
package-lock.json
generated
4127
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
79
package.json
79
package.json
@@ -33,8 +33,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.4.0",
|
||||
"@aws-sdk/client-s3": "3.971.0",
|
||||
"@faker-js/faker": "10.2.0",
|
||||
"@aws-sdk/client-s3": "3.989.0",
|
||||
"@faker-js/faker": "10.3.0",
|
||||
"@headlessui/react": "2.2.9",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
@@ -59,67 +59,66 @@
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@react-email/components": "1.0.2",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/tailwind": "2.0.2",
|
||||
"@react-email/components": "1.0.7",
|
||||
"@react-email/render": "2.0.4",
|
||||
"@react-email/tailwind": "2.0.4",
|
||||
"@simplewebauthn/browser": "13.2.2",
|
||||
"@simplewebauthn/server": "13.2.2",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.13.2",
|
||||
"axios": "1.13.5",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"cors": "2.8.6",
|
||||
"crypto-js": "4.2.0",
|
||||
"d3": "7.9.0",
|
||||
"date-fns": "4.1.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.1.0",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"glob": "13.0.0",
|
||||
"glob": "13.0.3",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.9.2",
|
||||
"ioredis": "5.9.3",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "0.562.0",
|
||||
"maxmind": "5.0.1",
|
||||
"lucide-react": "0.563.0",
|
||||
"maxmind": "5.0.5",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.5.9",
|
||||
"next-intl": "4.7.0",
|
||||
"next": "15.5.12",
|
||||
"next-intl": "4.8.2",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "7.0.11",
|
||||
"nodemailer": "8.0.1",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.17.1",
|
||||
"posthog-node": "5.23.0",
|
||||
"pg": "8.18.0",
|
||||
"posthog-node": "5.24.15",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.71.1",
|
||||
"react-icons": "5.5.0",
|
||||
"recharts": "2.15.4",
|
||||
"reodotdev": "1.0.0",
|
||||
"resend": "6.8.0",
|
||||
"semver": "7.7.3",
|
||||
"stripe": "20.2.0",
|
||||
"resend": "6.9.2",
|
||||
"semver": "7.7.4",
|
||||
"sshpk": "^1.18.0",
|
||||
"stripe": "20.3.1",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "^10.1.0",
|
||||
"uuid": "13.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
@@ -128,14 +127,15 @@
|
||||
"ws": "8.19.0",
|
||||
"yaml": "2.8.2",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.3.5",
|
||||
"zod": "4.3.6",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.51.2",
|
||||
"@dotenvx/dotenvx": "1.52.0",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/preview-server": "5.2.8",
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@tanstack/react-query-devtools": "5.91.1",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -144,30 +144,33 @@
|
||||
"@types/express": "5.0.6",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "24.10.2",
|
||||
"@types/nodemailer": "7.0.4",
|
||||
"@types/node": "25.2.3",
|
||||
"@types/nodemailer": "7.0.9",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.16.0",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "^1.17.4",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.35",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.8",
|
||||
"esbuild": "0.27.2",
|
||||
"drizzle-kit": "0.31.9",
|
||||
"esbuild": "0.27.3",
|
||||
"esbuild-node-externals": "1.20.1",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.8.0",
|
||||
"react-email": "5.2.5",
|
||||
"prettier": "3.8.1",
|
||||
"react-email": "5.2.8",
|
||||
"tailwindcss": "4.1.18",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.53.1"
|
||||
"typescript-eslint": "8.55.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,8 @@ export enum ActionsEnum {
|
||||
viewLogs = "viewLogs",
|
||||
exportLogs = "exportLogs",
|
||||
listApprovals = "listApprovals",
|
||||
updateApprovals = "updateApprovals"
|
||||
updateApprovals = "updateApprovals",
|
||||
signSshKey = "signSshKey"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
45
server/auth/canUserAccessSiteResource.ts
Normal file
45
server/auth/canUserAccessSiteResource.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { roleSiteResources, userSiteResources } from "@server/db";
|
||||
|
||||
export async function canUserAccessSiteResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleId
|
||||
}: {
|
||||
userId: string;
|
||||
resourceId: number;
|
||||
roleId: number;
|
||||
}): Promise<boolean> {
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleSiteResources.siteResourceId, resourceId),
|
||||
eq(roleSiteResources.roleId, roleId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (roleResourceAccess.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userResourceAccess = await db
|
||||
.select()
|
||||
.from(userSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userSiteResources.userId, userId),
|
||||
eq(userSiteResources.siteResourceId, resourceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (userResourceAccess.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
varchar,
|
||||
boolean,
|
||||
integer,
|
||||
bigint,
|
||||
real,
|
||||
text,
|
||||
index,
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { alias } from "yargs";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
real,
|
||||
serial,
|
||||
text,
|
||||
varchar
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const domains = pgTable("domains", {
|
||||
domainId: varchar("domainId").primaryKey(),
|
||||
@@ -55,7 +53,11 @@ export const orgs = pgTable("orgs", {
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0)
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||
isBillingOrg: boolean("isBillingOrg"),
|
||||
billingOrgId: varchar("billingOrgId")
|
||||
});
|
||||
|
||||
export const orgDomains = pgTable("orgDomains", {
|
||||
@@ -188,7 +190,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
||||
hcMethod: varchar("hcMethod").default("GET"),
|
||||
hcStatus: integer("hcStatus"), // http code
|
||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcHealth: text("hcHealth")
|
||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcTlsServerName: text("hcTlsServerName")
|
||||
});
|
||||
|
||||
@@ -218,7 +222,7 @@ export const siteResources = pgTable("siteResources", {
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
mode: varchar("mode").notNull(), // "host" | "cidr" | "port"
|
||||
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: varchar("protocol"), // only for port mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
@@ -228,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", {
|
||||
@@ -328,7 +336,8 @@ export const userOrgs = pgTable("userOrgs", {
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: boolean("isOwner").notNull().default(false),
|
||||
autoProvisioned: boolean("autoProvisioned").default(false)
|
||||
autoProvisioned: boolean("autoProvisioned").default(false),
|
||||
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
|
||||
});
|
||||
|
||||
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||
@@ -367,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", {
|
||||
@@ -984,6 +997,16 @@ export const deviceWebAuthCodes = pgTable("deviceWebAuthCodes", {
|
||||
})
|
||||
});
|
||||
|
||||
export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
||||
messageId: serial("messageId").primaryKey(),
|
||||
wsClientId: varchar("clientId"),
|
||||
messageType: varchar("messageType"),
|
||||
sentAt: bigint("sentAt", { mode: "number" }).notNull(),
|
||||
receivedAt: bigint("receivedAt", { mode: "number" }),
|
||||
error: text("error"),
|
||||
complete: boolean("complete").notNull().default(false)
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1044,3 +1067,6 @@ export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { no } from "zod/v4/locales";
|
||||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const domains = sqliteTable("domains", {
|
||||
domainId: text("domainId").primaryKey(),
|
||||
@@ -52,7 +45,11 @@ export const orgs = sqliteTable("orgs", {
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0)
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||
billingOrgId: text("billingOrgId")
|
||||
});
|
||||
|
||||
export const userDomains = sqliteTable("userDomains", {
|
||||
@@ -214,7 +211,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
}).default(true),
|
||||
hcMethod: text("hcMethod").default("GET"),
|
||||
hcStatus: integer("hcStatus"), // http code
|
||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcHealth: text("hcHealth")
|
||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcTlsServerName: text("hcTlsServerName")
|
||||
});
|
||||
|
||||
@@ -246,7 +245,7 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
mode: text("mode").notNull(), // "host" | "cidr" | "port"
|
||||
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: text("protocol"), // only for port mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
@@ -258,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", {
|
||||
@@ -638,7 +641,8 @@ export const userOrgs = sqliteTable("userOrgs", {
|
||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
||||
autoProvisioned: integer("autoProvisioned", {
|
||||
mode: "boolean"
|
||||
}).default(false)
|
||||
}).default(false),
|
||||
pamUsername: text("pamUsername") // cleaned username for ssh and such
|
||||
});
|
||||
|
||||
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||
@@ -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", {
|
||||
@@ -1080,6 +1090,16 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", {
|
||||
})
|
||||
});
|
||||
|
||||
export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
||||
messageId: integer("messageId").primaryKey({ autoIncrement: true }),
|
||||
wsClientId: text("clientId"),
|
||||
messageType: text("messageType"),
|
||||
sentAt: integer("sentAt").notNull(),
|
||||
receivedAt: integer("receivedAt"),
|
||||
error: text("error"),
|
||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1141,3 +1161,6 @@ export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||
export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum FeatureId {
|
||||
EGRESS_DATA_MB = "egressDataMb",
|
||||
DOMAINS = "domains",
|
||||
REMOTE_EXIT_NODES = "remoteExitNodes",
|
||||
ORGINIZATIONS = "organizations",
|
||||
TIER1 = "tier1"
|
||||
}
|
||||
|
||||
@@ -19,6 +20,8 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise<strin
|
||||
return "Domains";
|
||||
case FeatureId.REMOTE_EXIT_NODES:
|
||||
return "Remote Exit Nodes";
|
||||
case FeatureId.ORGINIZATIONS:
|
||||
return "Organizations";
|
||||
case FeatureId.TIER1:
|
||||
return "Home Lab";
|
||||
default:
|
||||
|
||||
@@ -7,18 +7,12 @@ export type LimitSet = Partial<{
|
||||
};
|
||||
}>;
|
||||
|
||||
export const sandboxLimitSet: LimitSet = {
|
||||
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
|
||||
[FeatureId.SITES]: { value: 1, description: "Sandbox limit" },
|
||||
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" },
|
||||
};
|
||||
|
||||
export const freeLimitSet: LimitSet = {
|
||||
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
|
||||
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
|
||||
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
||||
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
|
||||
};
|
||||
|
||||
export const tier1LimitSet: LimitSet = {
|
||||
@@ -26,6 +20,7 @@ export const tier1LimitSet: LimitSet = {
|
||||
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
||||
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
||||
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
|
||||
};
|
||||
|
||||
export const tier2LimitSet: LimitSet = {
|
||||
@@ -45,6 +40,10 @@ export const tier2LimitSet: LimitSet = {
|
||||
value: 3,
|
||||
description: "Team limit"
|
||||
},
|
||||
[FeatureId.ORGINIZATIONS]: {
|
||||
value: 1,
|
||||
description: "Team limit"
|
||||
}
|
||||
};
|
||||
|
||||
export const tier3LimitSet: LimitSet = {
|
||||
@@ -64,4 +63,8 @@ export const tier3LimitSet: LimitSet = {
|
||||
value: 20,
|
||||
description: "Business limit"
|
||||
},
|
||||
[FeatureId.ORGINIZATIONS]: {
|
||||
value: 5,
|
||||
description: "Business limit"
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,7 +14,8 @@ export enum TierFeature {
|
||||
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
||||
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
||||
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
||||
AutoProvisioning = "autoProvisioning" // handle downgrade by disabling auto provisioning
|
||||
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
|
||||
SshPam = "sshPam"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -46,5 +47,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
"tier3",
|
||||
"enterprise"
|
||||
],
|
||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"]
|
||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
||||
[TierFeature.SshPam]: ["enterprise"]
|
||||
};
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import { eq, sql, and } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import {
|
||||
db,
|
||||
usage,
|
||||
customers,
|
||||
sites,
|
||||
newts,
|
||||
limits,
|
||||
Usage,
|
||||
Limit,
|
||||
Transaction
|
||||
Transaction,
|
||||
orgs
|
||||
} from "@server/db";
|
||||
import { FeatureId, getFeatureMeterId } from "./features";
|
||||
import logger from "@server/logger";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import { build } from "@server/build";
|
||||
import { s3Client } from "@server/lib/s3";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
interface StripeEvent {
|
||||
identifier?: string;
|
||||
timestamp: number;
|
||||
event_name: string;
|
||||
payload: {
|
||||
value: number;
|
||||
stripe_customer_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function noop() {
|
||||
if (build !== "saas") {
|
||||
return true;
|
||||
@@ -37,41 +22,11 @@ export function noop() {
|
||||
}
|
||||
|
||||
export class UsageService {
|
||||
private bucketName: string | undefined;
|
||||
private events: StripeEvent[] = [];
|
||||
private lastUploadTime: number = Date.now();
|
||||
private isUploading: boolean = false;
|
||||
|
||||
constructor() {
|
||||
if (noop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this.bucketName = process.env.S3_BUCKET || undefined;
|
||||
|
||||
// // Periodically check and upload events
|
||||
// setInterval(() => {
|
||||
// this.checkAndUploadEvents().catch((err) => {
|
||||
// logger.error("Error in periodic event upload:", err);
|
||||
// });
|
||||
// }, 30000); // every 30 seconds
|
||||
|
||||
// // Handle graceful shutdown on SIGTERM
|
||||
// process.on("SIGTERM", async () => {
|
||||
// logger.info(
|
||||
// "SIGTERM received, uploading events before shutdown..."
|
||||
// );
|
||||
// await this.forceUpload();
|
||||
// logger.info("Events uploaded, proceeding with shutdown");
|
||||
// });
|
||||
|
||||
// // Handle SIGINT as well (Ctrl+C)
|
||||
// process.on("SIGINT", async () => {
|
||||
// logger.info("SIGINT received, uploading events before shutdown...");
|
||||
// await this.forceUpload();
|
||||
// logger.info("Events uploaded, proceeding with shutdown");
|
||||
// process.exit(0);
|
||||
// });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,28 +55,20 @@ export class UsageService {
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
// Get subscription data for this org (with caching)
|
||||
const customerId = await this.getCustomerId(orgId, featureId);
|
||||
|
||||
if (!customerId) {
|
||||
logger.warn(
|
||||
`No subscription data found for org ${orgId} and feature ${featureId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let usage;
|
||||
if (transaction) {
|
||||
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
|
||||
usage = await this.internalAddUsage(
|
||||
orgId,
|
||||
orgIdToUse,
|
||||
featureId,
|
||||
value,
|
||||
transaction
|
||||
);
|
||||
} else {
|
||||
await db.transaction(async (trx) => {
|
||||
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||
usage = await this.internalAddUsage(
|
||||
orgId,
|
||||
orgIdToUse,
|
||||
featureId,
|
||||
value,
|
||||
trx
|
||||
@@ -129,11 +76,6 @@ export class UsageService {
|
||||
});
|
||||
}
|
||||
|
||||
// Log event for Stripe
|
||||
// if (privateConfig.getRawPrivateConfig().flags.usage_reporting) {
|
||||
// await this.logStripeEvent(featureId, value, customerId);
|
||||
// }
|
||||
|
||||
return usage || null;
|
||||
} catch (error: any) {
|
||||
// Check if this is a deadlock error
|
||||
@@ -169,7 +111,7 @@ export class UsageService {
|
||||
}
|
||||
|
||||
private async internalAddUsage(
|
||||
orgId: string,
|
||||
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
||||
featureId: FeatureId,
|
||||
value: number,
|
||||
trx: Transaction
|
||||
@@ -188,17 +130,22 @@ export class UsageService {
|
||||
featureId,
|
||||
orgId,
|
||||
meterId,
|
||||
latestValue: value,
|
||||
instantaneousValue: value || 0,
|
||||
latestValue: value || 0,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: usage.usageId,
|
||||
set: {
|
||||
latestValue: sql`${usage.latestValue} + ${value}`
|
||||
instantaneousValue: sql`COALESCE(${usage.instantaneousValue}, 0) + ${value}`
|
||||
}
|
||||
})
|
||||
.returning();
|
||||
|
||||
logger.debug(
|
||||
`Added usage for org ${orgId} feature ${featureId}: +${value}, new instantaneousValue: ${returnUsage.instantaneousValue}`
|
||||
);
|
||||
|
||||
return returnUsage;
|
||||
}
|
||||
|
||||
@@ -221,18 +168,10 @@ export class UsageService {
|
||||
if (noop()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!customerId) {
|
||||
customerId =
|
||||
(await this.getCustomerId(orgId, featureId)) || undefined;
|
||||
if (!customerId) {
|
||||
logger.warn(
|
||||
`No subscription data found for org ${orgId} and feature ${featureId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const orgIdToUse = await this.getBillingOrg(orgId);
|
||||
|
||||
try {
|
||||
// Truncate value to 11 decimal places if provided
|
||||
if (value !== undefined && value !== null) {
|
||||
value = this.truncateValue(value);
|
||||
@@ -242,7 +181,7 @@ export class UsageService {
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// Get existing meter record
|
||||
const usageId = `${orgId}-${featureId}`;
|
||||
const usageId = `${orgIdToUse}-${featureId}`;
|
||||
// Get current usage record
|
||||
[currentUsage] = await trx
|
||||
.select()
|
||||
@@ -264,7 +203,7 @@ export class UsageService {
|
||||
await trx.insert(usage).values({
|
||||
usageId,
|
||||
featureId,
|
||||
orgId,
|
||||
orgId: orgIdToUse,
|
||||
meterId,
|
||||
instantaneousValue: value || 0,
|
||||
latestValue: value || 0,
|
||||
@@ -278,7 +217,7 @@ export class UsageService {
|
||||
// }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to update count usage for ${orgId}/${featureId}:`,
|
||||
`Failed to update count usage for ${orgIdToUse}/${featureId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
@@ -288,7 +227,9 @@ export class UsageService {
|
||||
orgId: string,
|
||||
featureId: FeatureId
|
||||
): Promise<string | null> {
|
||||
const cacheKey = `customer_${orgId}_${featureId}`;
|
||||
const orgIdToUse = await this.getBillingOrg(orgId);
|
||||
|
||||
const cacheKey = `customer_${orgIdToUse}_${featureId}`;
|
||||
const cached = cache.get<string>(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
@@ -302,7 +243,7 @@ export class UsageService {
|
||||
customerId: customers.customerId
|
||||
})
|
||||
.from(customers)
|
||||
.where(eq(customers.orgId, orgId))
|
||||
.where(eq(customers.orgId, orgIdToUse))
|
||||
.limit(1);
|
||||
|
||||
if (!customer) {
|
||||
@@ -317,112 +258,13 @@ export class UsageService {
|
||||
return customerId;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to get subscription data for ${orgId}/${featureId}:`,
|
||||
`Failed to get subscription data for ${orgIdToUse}/${featureId}:`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async logStripeEvent(
|
||||
featureId: FeatureId,
|
||||
value: number,
|
||||
customerId: string
|
||||
): Promise<void> {
|
||||
// Truncate value to 11 decimal places before sending to Stripe
|
||||
const truncatedValue = this.truncateValue(value);
|
||||
|
||||
const event: StripeEvent = {
|
||||
identifier: uuidv4(),
|
||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||
event_name: featureId,
|
||||
payload: {
|
||||
value: truncatedValue,
|
||||
stripe_customer_id: customerId
|
||||
}
|
||||
};
|
||||
|
||||
this.addEventToMemory(event);
|
||||
await this.checkAndUploadEvents();
|
||||
}
|
||||
|
||||
private addEventToMemory(event: StripeEvent): void {
|
||||
if (!this.bucketName) {
|
||||
logger.warn(
|
||||
"S3 bucket name is not configured, skipping event storage."
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
private async checkAndUploadEvents(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const timeSinceLastUpload = now - this.lastUploadTime;
|
||||
|
||||
// Check if at least 1 minute has passed since last upload
|
||||
if (timeSinceLastUpload >= 60000 && this.events.length > 0) {
|
||||
await this.uploadEventsToS3();
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadEventsToS3(): Promise<void> {
|
||||
if (!this.bucketName) {
|
||||
logger.warn(
|
||||
"S3 bucket name is not configured, skipping S3 upload."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already uploading
|
||||
if (this.isUploading) {
|
||||
logger.debug("Already uploading events, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUploading = true;
|
||||
|
||||
try {
|
||||
// Take a snapshot of current events and clear the array
|
||||
const eventsToUpload = [...this.events];
|
||||
this.events = [];
|
||||
this.lastUploadTime = Date.now();
|
||||
|
||||
const fileName = this.generateEventFileName();
|
||||
const fileContent = JSON.stringify(eventsToUpload, null, 2);
|
||||
|
||||
// Upload to S3
|
||||
const uploadCommand = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: fileName,
|
||||
Body: fileContent,
|
||||
ContentType: "application/json"
|
||||
});
|
||||
|
||||
await s3Client.send(uploadCommand);
|
||||
|
||||
logger.info(
|
||||
`Uploaded ${fileName} to S3 with ${eventsToUpload.length} events`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to upload events to S3:", error);
|
||||
// Note: Events are lost if upload fails. In a production system,
|
||||
// you might want to add the events back to the array or implement retry logic
|
||||
} finally {
|
||||
this.isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private generateEventFileName(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const uuid = uuidv4().substring(0, 8);
|
||||
return `events-${timestamp}-${uuid}.json`;
|
||||
}
|
||||
|
||||
public async getUsage(
|
||||
orgId: string,
|
||||
featureId: FeatureId,
|
||||
@@ -432,7 +274,9 @@ export class UsageService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usageId = `${orgId}-${featureId}`;
|
||||
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||
|
||||
const usageId = `${orgIdToUse}-${featureId}`;
|
||||
|
||||
try {
|
||||
const [result] = await trx
|
||||
@@ -444,7 +288,7 @@ export class UsageService {
|
||||
if (!result) {
|
||||
// Lets create one if it doesn't exist using upsert to handle race conditions
|
||||
logger.info(
|
||||
`Creating new usage record for ${orgId}/${featureId}`
|
||||
`Creating new usage record for ${orgIdToUse}/${featureId}`
|
||||
);
|
||||
const meterId = getFeatureMeterId(featureId);
|
||||
|
||||
@@ -454,7 +298,7 @@ export class UsageService {
|
||||
.values({
|
||||
usageId,
|
||||
featureId,
|
||||
orgId,
|
||||
orgId: orgIdToUse,
|
||||
meterId,
|
||||
latestValue: 0,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
@@ -476,7 +320,7 @@ export class UsageService {
|
||||
} catch (insertError) {
|
||||
// Fallback: try to fetch existing record in case of any insert issues
|
||||
logger.warn(
|
||||
`Insert failed for ${orgId}/${featureId}, attempting to fetch existing record:`,
|
||||
`Insert failed for ${orgIdToUse}/${featureId}, attempting to fetch existing record:`,
|
||||
insertError
|
||||
);
|
||||
const [existingUsage] = await trx
|
||||
@@ -491,19 +335,41 @@ export class UsageService {
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to get usage for ${orgId}/${featureId}:`,
|
||||
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async forceUpload(): Promise<void> {
|
||||
if (this.events.length > 0) {
|
||||
// Force upload regardless of time
|
||||
this.lastUploadTime = 0; // Reset to force upload
|
||||
await this.uploadEventsToS3();
|
||||
public async getBillingOrg(
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<string> {
|
||||
let orgIdToUse = orgId;
|
||||
|
||||
// get the org
|
||||
const [org] = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
throw new Error(`Organization with ID ${orgId} not found`);
|
||||
}
|
||||
|
||||
if (!org.isBillingOrg) {
|
||||
if (org.billingOrgId) {
|
||||
orgIdToUse = org.billingOrgId;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Organization ${orgId} is not a billing org and does not have a billingOrgId set`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return orgIdToUse;
|
||||
}
|
||||
|
||||
public async checkLimitSet(
|
||||
@@ -515,6 +381,9 @@ export class UsageService {
|
||||
if (noop()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||
|
||||
// This method should check the current usage against the limits set for the organization
|
||||
// and kick out all of the sites on the org
|
||||
let hasExceededLimits = false;
|
||||
@@ -528,7 +397,7 @@ export class UsageService {
|
||||
.from(limits)
|
||||
.where(
|
||||
and(
|
||||
eq(limits.orgId, orgId),
|
||||
eq(limits.orgId, orgIdToUse),
|
||||
eq(limits.featureId, featureId)
|
||||
)
|
||||
);
|
||||
@@ -537,11 +406,11 @@ export class UsageService {
|
||||
orgLimits = await trx
|
||||
.select()
|
||||
.from(limits)
|
||||
.where(eq(limits.orgId, orgId));
|
||||
.where(eq(limits.orgId, orgIdToUse));
|
||||
}
|
||||
|
||||
if (orgLimits.length === 0) {
|
||||
logger.debug(`No limits set for org ${orgId}`);
|
||||
logger.debug(`No limits set for org ${orgIdToUse}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -552,7 +421,7 @@ export class UsageService {
|
||||
currentUsage = usage;
|
||||
} else {
|
||||
currentUsage = await this.getUsage(
|
||||
orgId,
|
||||
orgIdToUse,
|
||||
limit.featureId as FeatureId,
|
||||
trx
|
||||
);
|
||||
@@ -563,10 +432,10 @@ export class UsageService {
|
||||
currentUsage?.latestValue ||
|
||||
0;
|
||||
logger.debug(
|
||||
`Current usage for org ${orgId} on feature ${limit.featureId}: ${usageValue}`
|
||||
`Current usage for org ${orgIdToUse} on feature ${limit.featureId}: ${usageValue}`
|
||||
);
|
||||
logger.debug(
|
||||
`Limit for org ${orgId} on feature ${limit.featureId}: ${limit.value}`
|
||||
`Limit for org ${orgIdToUse} on feature ${limit.featureId}: ${limit.value}`
|
||||
);
|
||||
if (
|
||||
currentUsage &&
|
||||
@@ -574,7 +443,7 @@ export class UsageService {
|
||||
usageValue > limit.value
|
||||
) {
|
||||
logger.debug(
|
||||
`Org ${orgId} has exceeded limit for ${limit.featureId}: ` +
|
||||
`Org ${orgIdToUse} has exceeded limit for ${limit.featureId}: ` +
|
||||
`${usageValue} > ${limit.value}`
|
||||
);
|
||||
hasExceededLimits = true;
|
||||
@@ -582,7 +451,7 @@ export class UsageService {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error checking limits for org ${orgId}:`, error);
|
||||
logger.error(`Error checking limits for org ${orgIdToUse}:`, error);
|
||||
}
|
||||
|
||||
return hasExceededLimits;
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { isValidCIDR } from "@server/lib/validators";
|
||||
import { getNextAvailableOrgSubnet } from "@server/lib/ip";
|
||||
import {
|
||||
actions,
|
||||
apiKeyOrg,
|
||||
apiKeys,
|
||||
db,
|
||||
domains,
|
||||
Org,
|
||||
orgDomains,
|
||||
orgs,
|
||||
roleActions,
|
||||
roles,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role";
|
||||
import { FeatureId, limitsService, sandboxLimitSet } from "@server/lib/billing";
|
||||
import { createCustomer } from "#dynamic/lib/billing";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export async function createUserAccountOrg(
|
||||
userId: string,
|
||||
userEmail: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
org?: {
|
||||
orgId: string;
|
||||
name: string;
|
||||
subnet: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
// const subnet = await getNextAvailableOrgSubnet();
|
||||
const orgId = "org_" + userId;
|
||||
const name = `${userEmail}'s Organization`;
|
||||
|
||||
// if (!isValidCIDR(subnet)) {
|
||||
// return {
|
||||
// success: false,
|
||||
// error: "Invalid subnet format. Please provide a valid CIDR notation."
|
||||
// };
|
||||
// }
|
||||
|
||||
// // make sure the subnet is unique
|
||||
// const subnetExists = await db
|
||||
// .select()
|
||||
// .from(orgs)
|
||||
// .where(eq(orgs.subnet, subnet))
|
||||
// .limit(1);
|
||||
|
||||
// if (subnetExists.length > 0) {
|
||||
// return { success: false, error: `Subnet ${subnet} already exists` };
|
||||
// }
|
||||
|
||||
// make sure the orgId is unique
|
||||
const orgExists = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (orgExists.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Organization with ID ${orgId} already exists`
|
||||
};
|
||||
}
|
||||
|
||||
let error = "";
|
||||
let org: Org | null = null;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const allDomains = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.configManaged, true));
|
||||
|
||||
const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group;
|
||||
|
||||
const newOrg = await trx
|
||||
.insert(orgs)
|
||||
.values({
|
||||
orgId,
|
||||
name,
|
||||
// subnet
|
||||
subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs?
|
||||
utilitySubnet: utilitySubnet,
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (newOrg.length === 0) {
|
||||
error = "Failed to create organization";
|
||||
trx.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
org = newOrg[0];
|
||||
|
||||
// Create admin role within the same transaction
|
||||
const [insertedRole] = await trx
|
||||
.insert(roles)
|
||||
.values({
|
||||
orgId: newOrg[0].orgId,
|
||||
isAdmin: true,
|
||||
name: "Admin",
|
||||
description: "Admin role with the most permissions"
|
||||
})
|
||||
.returning({ roleId: roles.roleId });
|
||||
|
||||
if (!insertedRole || !insertedRole.roleId) {
|
||||
error = "Failed to create Admin role";
|
||||
trx.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
const roleId = insertedRole.roleId;
|
||||
|
||||
// Get all actions and create role actions
|
||||
const actionIds = await trx.select().from(actions).execute();
|
||||
|
||||
if (actionIds.length > 0) {
|
||||
await trx.insert(roleActions).values(
|
||||
actionIds.map((action) => ({
|
||||
roleId,
|
||||
actionId: action.actionId,
|
||||
orgId: newOrg[0].orgId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (allDomains.length) {
|
||||
await trx.insert(orgDomains).values(
|
||||
allDomains.map((domain) => ({
|
||||
orgId: newOrg[0].orgId,
|
||||
domainId: domain.domainId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await trx.insert(userOrgs).values({
|
||||
userId,
|
||||
orgId: newOrg[0].orgId,
|
||||
roleId: roleId,
|
||||
isOwner: true
|
||||
});
|
||||
|
||||
const memberRole = await trx
|
||||
.insert(roles)
|
||||
.values({
|
||||
name: "Member",
|
||||
description: "Members can only view resources",
|
||||
orgId
|
||||
})
|
||||
.returning();
|
||||
|
||||
await trx.insert(roleActions).values(
|
||||
defaultRoleAllowedActions.map((action) => ({
|
||||
roleId: memberRole[0].roleId,
|
||||
actionId: action,
|
||||
orgId
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
await limitsService.applyLimitSetToOrg(orgId, sandboxLimitSet);
|
||||
|
||||
if (!org) {
|
||||
return { success: false, error: "Failed to create org" };
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to create org: ${error}`
|
||||
};
|
||||
}
|
||||
|
||||
// make sure we have the stripe customer
|
||||
const customerId = await createCustomer(orgId, userEmail);
|
||||
|
||||
if (customerId) {
|
||||
await usageService.updateCount(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org
|
||||
}
|
||||
|
||||
return {
|
||||
org: {
|
||||
orgId,
|
||||
name,
|
||||
// subnet
|
||||
subnet: "100.90.128.0/24"
|
||||
},
|
||||
success: true
|
||||
};
|
||||
}
|
||||
@@ -4,14 +4,18 @@ import {
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
domains,
|
||||
exitNodeOrgs,
|
||||
exitNodes,
|
||||
olms,
|
||||
orgDomains,
|
||||
orgs,
|
||||
remoteExitNodes,
|
||||
resources,
|
||||
sites
|
||||
sites,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { newts, newtSessions } from "@server/db";
|
||||
import { eq, and, inArray, sql } from "drizzle-orm";
|
||||
import { eq, and, inArray, sql, count, countDistinct } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
@@ -19,6 +23,8 @@ import { sendToClient } from "#dynamic/routers/ws";
|
||||
import { deletePeer } from "@server/routers/gerbil/peers";
|
||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||
import { usageService } from "./billing/usageService";
|
||||
import { FeatureId } from "./billing";
|
||||
|
||||
export type DeleteOrgByIdResult = {
|
||||
deletedNewtIds: string[];
|
||||
@@ -60,6 +66,11 @@ export async function deleteOrgById(
|
||||
const deletedNewtIds: string[] = [];
|
||||
const olmsToTerminate: string[] = [];
|
||||
|
||||
let domainCount: number | null = null;
|
||||
let siteCount: number | null = null;
|
||||
let userCount: number | null = null;
|
||||
let remoteExitNodeCount: number | null = null;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
for (const site of orgSites) {
|
||||
if (site.pubKey) {
|
||||
@@ -137,9 +148,74 @@ export async function deleteOrgById(
|
||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||
}
|
||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||
|
||||
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
||||
|
||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||
|
||||
if (org.billingOrgId) {
|
||||
const billingOrgs = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.billingOrgId, org.billingOrgId));
|
||||
|
||||
if (billingOrgs.length > 0) {
|
||||
const billingOrgIds = billingOrgs.map((org) => org.orgId);
|
||||
|
||||
const [domainCountRes] = await trx
|
||||
.select({ count: count() })
|
||||
.from(orgDomains)
|
||||
.where(inArray(orgDomains.orgId, billingOrgIds));
|
||||
|
||||
domainCount = domainCountRes.count;
|
||||
|
||||
const [siteCountRes] = await trx
|
||||
.select({ count: count() })
|
||||
.from(sites)
|
||||
.where(inArray(sites.orgId, billingOrgIds));
|
||||
|
||||
siteCount = siteCountRes.count;
|
||||
|
||||
const [userCountRes] = await trx
|
||||
.select({ count: countDistinct(userOrgs.userId) })
|
||||
.from(userOrgs)
|
||||
.where(inArray(userOrgs.orgId, billingOrgIds));
|
||||
|
||||
userCount = userCountRes.count;
|
||||
|
||||
const [remoteExitNodeCountRes] = await trx
|
||||
.select({ count: countDistinct(exitNodeOrgs.exitNodeId) })
|
||||
.from(exitNodeOrgs)
|
||||
.where(inArray(exitNodeOrgs.orgId, billingOrgIds));
|
||||
|
||||
remoteExitNodeCount = remoteExitNodeCountRes.count;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (org.billingOrgId) {
|
||||
usageService.updateCount(
|
||||
org.billingOrgId,
|
||||
FeatureId.DOMAINS,
|
||||
domainCount ?? 0
|
||||
);
|
||||
usageService.updateCount(
|
||||
org.billingOrgId,
|
||||
FeatureId.SITES,
|
||||
siteCount ?? 0
|
||||
);
|
||||
usageService.updateCount(
|
||||
org.billingOrgId,
|
||||
FeatureId.USERS,
|
||||
userCount ?? 0
|
||||
);
|
||||
usageService.updateCount(
|
||||
org.billingOrgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
remoteExitNodeCount ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
return { deletedNewtIds, olmsToTerminate };
|
||||
}
|
||||
|
||||
|
||||
142
server/lib/userOrg.ts
Normal file
142
server/lib/userOrg.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
db,
|
||||
Org,
|
||||
orgs,
|
||||
resources,
|
||||
siteResources,
|
||||
sites,
|
||||
Transaction,
|
||||
UserOrg,
|
||||
userOrgs,
|
||||
userResources,
|
||||
userSiteResources,
|
||||
userSites
|
||||
} from "@server/db";
|
||||
import { eq, and, inArray, ne, exists } from "drizzle-orm";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
|
||||
export async function assignUserToOrg(
|
||||
org: Org,
|
||||
values: typeof userOrgs.$inferInsert,
|
||||
trx: Transaction | typeof db = db
|
||||
) {
|
||||
const [userOrg] = await trx.insert(userOrgs).values(values).returning();
|
||||
|
||||
// calculate if the user is in any other of the orgs before we count it as an add to the billing org
|
||||
if (org.billingOrgId) {
|
||||
const otherBillingOrgs = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(
|
||||
and(
|
||||
eq(orgs.billingOrgId, org.billingOrgId),
|
||||
ne(orgs.orgId, org.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||
|
||||
const orgsInBillingDomainThatTheUserIsStillIn = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userOrg.userId),
|
||||
inArray(userOrgs.orgId, billingOrgIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
|
||||
await usageService.add(org.orgId, FeatureId.USERS, 1, trx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUserFromOrg(
|
||||
org: Org,
|
||||
userId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
) {
|
||||
await trx
|
||||
.delete(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
|
||||
|
||||
await trx.delete(userResources).where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
exists(
|
||||
trx
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, userResources.resourceId),
|
||||
eq(resources.orgId, org.orgId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await trx.delete(userSiteResources).where(
|
||||
and(
|
||||
eq(userSiteResources.userId, userId),
|
||||
exists(
|
||||
trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
siteResources.siteResourceId,
|
||||
userSiteResources.siteResourceId
|
||||
),
|
||||
eq(siteResources.orgId, org.orgId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await trx.delete(userSites).where(
|
||||
and(
|
||||
eq(userSites.userId, userId),
|
||||
exists(
|
||||
db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, userSites.siteId),
|
||||
eq(sites.orgId, org.orgId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// calculate if the user is in any other of the orgs before we count it as an remove to the billing org
|
||||
if (org.billingOrgId) {
|
||||
const billingOrgs = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.billingOrgId, org.billingOrgId));
|
||||
|
||||
const billingOrgIds = billingOrgs.map((o) => o.orgId);
|
||||
|
||||
const orgsInBillingDomainThatTheUserIsStillIn = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
inArray(userOrgs.orgId, billingOrgIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
|
||||
await usageService.add(org.orgId, FeatureId.USERS, -1, trx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -16,5 +16,6 @@ export enum OpenAPITags {
|
||||
Client = "Client",
|
||||
ApiKey = "API Key",
|
||||
Domain = "Domain",
|
||||
Blueprint = "Blueprint"
|
||||
Blueprint = "Blueprint",
|
||||
Ssh = "SSH"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
import { build } from "@server/build";
|
||||
import { db, customers, subscriptions } from "@server/db";
|
||||
import { db, customers, subscriptions, orgs } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
|
||||
@@ -27,37 +28,61 @@ export async function getOrgTierData(
|
||||
}
|
||||
|
||||
try {
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
let orgIdToUse = org.orgId;
|
||||
if (!org.isBillingOrg) {
|
||||
if (!org.billingOrgId) {
|
||||
logger.warn(
|
||||
`Org ${orgId} is not a billing org and does not have a billingOrgId`
|
||||
);
|
||||
return { tier, active };
|
||||
}
|
||||
orgIdToUse = org.billingOrgId;
|
||||
}
|
||||
|
||||
// Get customer for org
|
||||
const [customer] = await db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.orgId, orgId))
|
||||
.where(eq(customers.orgId, orgIdToUse))
|
||||
.limit(1);
|
||||
|
||||
if (customer) {
|
||||
// Query for active subscriptions that are not license type
|
||||
const [subscription] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptions.customerId, customer.customerId),
|
||||
eq(subscriptions.status, "active"),
|
||||
ne(subscriptions.type, "license")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!customer) {
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
// Validate that subscription.type is one of the expected tier values
|
||||
if (
|
||||
subscription.type === "tier1" ||
|
||||
subscription.type === "tier2" ||
|
||||
subscription.type === "tier3"
|
||||
) {
|
||||
tier = subscription.type;
|
||||
active = true;
|
||||
}
|
||||
// Query for active subscriptions that are not license type
|
||||
const [subscription] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptions.customerId, customer.customerId),
|
||||
eq(subscriptions.status, "active"),
|
||||
ne(subscriptions.type, "license")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (subscription) {
|
||||
// Validate that subscription.type is one of the expected tier values
|
||||
if (
|
||||
subscription.type === "tier1" ||
|
||||
subscription.type === "tier2" ||
|
||||
subscription.type === "tier3" ||
|
||||
subscription.type === "enterprise"
|
||||
) {
|
||||
tier = subscription.type;
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
447
server/private/lib/sshCA.ts
Normal file
447
server/private/lib/sshCA.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import * as crypto from "crypto";
|
||||
|
||||
/**
|
||||
* SSH CA "Server" - Pure TypeScript Implementation
|
||||
*
|
||||
* This module provides basic SSH Certificate Authority functionality using
|
||||
* only Node.js built-in crypto module. No external dependencies or subprocesses.
|
||||
*
|
||||
* Usage:
|
||||
* 1. generateCA() - Creates a new CA key pair, returns CA info including the
|
||||
* TrustedUserCAKeys line to add to servers
|
||||
* 2. signPublicKey() - Signs a user's public key with the CA, returns a certificate
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// SSH Wire Format Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Encode a string in SSH wire format (4-byte length prefix + data)
|
||||
*/
|
||||
function encodeString(data: Buffer | string): Buffer {
|
||||
const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(buf.length, 0);
|
||||
return Buffer.concat([len, buf]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a uint32 in SSH wire format (big-endian)
|
||||
*/
|
||||
function encodeUInt32(value: number): Buffer {
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeUInt32BE(value, 0);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a uint64 in SSH wire format (big-endian)
|
||||
*/
|
||||
function encodeUInt64(value: bigint): Buffer {
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeBigUInt64BE(value, 0);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } {
|
||||
const len = data.readUInt32BE(offset);
|
||||
const value = data.subarray(offset + 4, offset + 4 + len);
|
||||
return { value, newOffset: offset + 4 + len };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH Public Key Parsing/Encoding
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse an OpenSSH public key line (e.g., "ssh-ed25519 AAAA... comment")
|
||||
*/
|
||||
function parseOpenSSHPublicKey(pubKeyLine: string): {
|
||||
keyType: string;
|
||||
keyData: Buffer;
|
||||
comment: string;
|
||||
} {
|
||||
const parts = pubKeyLine.trim().split(/\s+/);
|
||||
if (parts.length < 2) {
|
||||
throw new Error("Invalid public key format");
|
||||
}
|
||||
|
||||
const keyType = parts[0];
|
||||
const keyData = Buffer.from(parts[1], "base64");
|
||||
const comment = parts.slice(2).join(" ") || "";
|
||||
|
||||
// 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}`
|
||||
);
|
||||
}
|
||||
|
||||
return { keyType, keyData, comment };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an Ed25519 public key in OpenSSH format
|
||||
*/
|
||||
function encodeEd25519PublicKey(publicKey: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
encodeString("ssh-ed25519"),
|
||||
encodeString(publicKey)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a public key blob as an OpenSSH public key line
|
||||
*/
|
||||
function formatOpenSSHPublicKey(keyBlob: Buffer, comment: string = ""): string {
|
||||
const { value: keyType } = decodeString(keyBlob, 0);
|
||||
const base64 = keyBlob.toString("base64");
|
||||
return `${keyType.toString("utf8")} ${base64}${comment ? " " + comment : ""}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH Certificate Building
|
||||
// ============================================================================
|
||||
|
||||
interface CertificateOptions {
|
||||
/** Serial number for the certificate */
|
||||
serial?: bigint;
|
||||
/** Certificate type: 1 = user, 2 = host */
|
||||
certType?: number;
|
||||
/** Key ID (usually username or identifier) */
|
||||
keyId: string;
|
||||
/** List of valid principals (usernames the cert is valid for) */
|
||||
validPrincipals: string[];
|
||||
/** Valid after timestamp (seconds since epoch) */
|
||||
validAfter?: bigint;
|
||||
/** Valid before timestamp (seconds since epoch) */
|
||||
validBefore?: bigint;
|
||||
/** Critical options (usually empty for user certs) */
|
||||
criticalOptions?: Map<string, string>;
|
||||
/** Extensions to enable */
|
||||
extensions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the extensions section of the certificate
|
||||
*/
|
||||
function buildExtensions(extensions: string[]): Buffer {
|
||||
// Extensions are a series of name-value pairs, sorted by name
|
||||
// For boolean extensions, the value is empty
|
||||
const sortedExtensions = [...extensions].sort();
|
||||
|
||||
const parts: Buffer[] = [];
|
||||
for (const ext of sortedExtensions) {
|
||||
parts.push(encodeString(ext));
|
||||
parts.push(encodeString("")); // Empty value for boolean extensions
|
||||
}
|
||||
|
||||
return encodeString(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the critical options section
|
||||
*/
|
||||
function buildCriticalOptions(options: Map<string, string>): Buffer {
|
||||
const sortedKeys = [...options.keys()].sort();
|
||||
|
||||
const parts: Buffer[] = [];
|
||||
for (const key of sortedKeys) {
|
||||
parts.push(encodeString(key));
|
||||
parts.push(encodeString(encodeString(options.get(key)!)));
|
||||
}
|
||||
|
||||
return encodeString(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the valid principals section
|
||||
*/
|
||||
function buildPrincipals(principals: string[]): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
for (const principal of principals) {
|
||||
parts.push(encodeString(principal));
|
||||
}
|
||||
return encodeString(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the raw Ed25519 public key from an OpenSSH public key blob
|
||||
*/
|
||||
function extractEd25519PublicKey(keyBlob: Buffer): Buffer {
|
||||
const { newOffset } = decodeString(keyBlob, 0); // Skip key type
|
||||
const { value: publicKey } = decodeString(keyBlob, newOffset);
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CA Interface
|
||||
// ============================================================================
|
||||
|
||||
export interface CAKeyPair {
|
||||
/** CA private key in PEM format (keep this secret!) */
|
||||
privateKeyPem: string;
|
||||
/** CA public key in PEM format */
|
||||
publicKeyPem: string;
|
||||
/** CA public key in OpenSSH format (for TrustedUserCAKeys) */
|
||||
publicKeyOpenSSH: string;
|
||||
/** Raw CA public key bytes (Ed25519) */
|
||||
publicKeyRaw: Buffer;
|
||||
}
|
||||
|
||||
export interface SignedCertificate {
|
||||
/** The certificate in OpenSSH format (save as id_ed25519-cert.pub or similar) */
|
||||
certificate: string;
|
||||
/** The certificate type string */
|
||||
certType: string;
|
||||
/** Serial number */
|
||||
serial: bigint;
|
||||
/** Key ID */
|
||||
keyId: string;
|
||||
/** Valid principals */
|
||||
validPrincipals: string[];
|
||||
/** Valid from timestamp */
|
||||
validAfter: Date;
|
||||
/** Valid until timestamp */
|
||||
validBefore: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a new SSH Certificate Authority key pair.
|
||||
*
|
||||
* Returns the CA keys and the line to add to /etc/ssh/sshd_config:
|
||||
* TrustedUserCAKeys /etc/ssh/ca.pub
|
||||
*
|
||||
* Then save the publicKeyOpenSSH to /etc/ssh/ca.pub on the server.
|
||||
*
|
||||
* @param comment - Optional comment for the CA public key
|
||||
* @returns CA key pair and configuration info
|
||||
*/
|
||||
export function generateCA(comment: string = "pangolin-ssh-ca"): CAKeyPair {
|
||||
// Generate Ed25519 key pair
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", {
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
||||
});
|
||||
|
||||
// Get raw public key bytes
|
||||
const pubKeyObj = crypto.createPublicKey(publicKey);
|
||||
const rawPubKey = pubKeyObj.export({ type: "spki", format: "der" });
|
||||
// Ed25519 SPKI format: 12 byte header + 32 byte key
|
||||
const ed25519PubKey = rawPubKey.subarray(rawPubKey.length - 32);
|
||||
|
||||
// Create OpenSSH format public key
|
||||
const pubKeyBlob = encodeEd25519PublicKey(ed25519PubKey);
|
||||
const publicKeyOpenSSH = formatOpenSSHPublicKey(pubKeyBlob, comment);
|
||||
|
||||
return {
|
||||
privateKeyPem: privateKey,
|
||||
publicKeyPem: publicKey,
|
||||
publicKeyOpenSSH,
|
||||
publicKeyRaw: ed25519PubKey
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export async function getOrgCAKeys(
|
||||
orgId: string,
|
||||
decryptionKey: string
|
||||
): Promise<CAKeyPair | null> {
|
||||
const { db, orgs } = await import("@server/db");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const { decrypt } = await import("@server/lib/crypto");
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
sshCaPrivateKey: orgs.sshCaPrivateKey,
|
||||
sshCaPublicKey: orgs.sshCaPublicKey
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org || !org.sshCaPrivateKey || !org.sshCaPublicKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const privateKeyPem = decrypt(org.sshCaPrivateKey, decryptionKey);
|
||||
|
||||
// Extract raw public key from the OpenSSH format
|
||||
const { keyData } = parseOpenSSHPublicKey(org.sshCaPublicKey);
|
||||
const { newOffset } = decodeString(keyData, 0); // Skip key type
|
||||
const { value: publicKeyRaw } = decodeString(keyData, newOffset);
|
||||
|
||||
// Get PEM format of public key
|
||||
const pubKeyObj = crypto.createPublicKey({
|
||||
key: privateKeyPem,
|
||||
format: "pem"
|
||||
});
|
||||
const publicKeyPem = pubKeyObj.export({
|
||||
type: "spki",
|
||||
format: "pem"
|
||||
}) as string;
|
||||
|
||||
return {
|
||||
privateKeyPem,
|
||||
publicKeyPem,
|
||||
publicKeyOpenSSH: org.sshCaPublicKey,
|
||||
publicKeyRaw
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a user's SSH public key with the CA, producing a certificate.
|
||||
*
|
||||
* The resulting certificate should be saved alongside the user's private key
|
||||
* with a -cert.pub suffix. For example:
|
||||
* - Private key: ~/.ssh/id_ed25519
|
||||
* - Certificate: ~/.ssh/id_ed25519-cert.pub
|
||||
*
|
||||
* @param caPrivateKeyPem - CA private key in PEM format
|
||||
* @param userPublicKeyLine - User's public key in OpenSSH format
|
||||
* @param options - Certificate options (principals, validity, etc.)
|
||||
* @returns Signed certificate
|
||||
*/
|
||||
export function signPublicKey(
|
||||
caPrivateKeyPem: string,
|
||||
userPublicKeyLine: string,
|
||||
options: CertificateOptions
|
||||
): SignedCertificate {
|
||||
// Parse the user's public key
|
||||
const { keyType, keyData } = parseOpenSSHPublicKey(userPublicKeyLine);
|
||||
|
||||
// Determine certificate type string
|
||||
let certTypeString: string;
|
||||
if (keyType === "ssh-ed25519") {
|
||||
certTypeString = "ssh-ed25519-cert-v01@openssh.com";
|
||||
} else if (keyType === "ssh-rsa") {
|
||||
certTypeString = "ssh-rsa-cert-v01@openssh.com";
|
||||
} else if (keyType === "ecdsa-sha2-nistp256") {
|
||||
certTypeString = "ecdsa-sha2-nistp256-cert-v01@openssh.com";
|
||||
} else if (keyType === "ecdsa-sha2-nistp384") {
|
||||
certTypeString = "ecdsa-sha2-nistp384-cert-v01@openssh.com";
|
||||
} else if (keyType === "ecdsa-sha2-nistp521") {
|
||||
certTypeString = "ecdsa-sha2-nistp521-cert-v01@openssh.com";
|
||||
} else {
|
||||
throw new Error(`Unsupported key type: ${keyType}`);
|
||||
}
|
||||
|
||||
// Get CA public key from private key
|
||||
const caPrivKey = crypto.createPrivateKey(caPrivateKeyPem);
|
||||
const caPubKey = crypto.createPublicKey(caPrivKey);
|
||||
const caRawPubKey = caPubKey.export({ type: "spki", format: "der" });
|
||||
const caEd25519PubKey = caRawPubKey.subarray(caRawPubKey.length - 32);
|
||||
const caPubKeyBlob = encodeEd25519PublicKey(caEd25519PubKey);
|
||||
|
||||
// Set defaults
|
||||
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
|
||||
|
||||
// Default extensions for user certificates
|
||||
const defaultExtensions = [
|
||||
"permit-X11-forwarding",
|
||||
"permit-agent-forwarding",
|
||||
"permit-port-forwarding",
|
||||
"permit-pty",
|
||||
"permit-user-rc"
|
||||
];
|
||||
const extensions = options.extensions ?? defaultExtensions;
|
||||
const criticalOptions = options.criticalOptions ?? new Map();
|
||||
|
||||
// Generate nonce (random bytes)
|
||||
const nonce = crypto.randomBytes(32);
|
||||
|
||||
// Extract the public key portion from the user's key blob
|
||||
// For Ed25519: skip the key type string, get the public key (already encoded)
|
||||
let userKeyPortion: Buffer;
|
||||
if (keyType === "ssh-ed25519") {
|
||||
// Skip the key type string, take the rest (which is encodeString(32-byte-key))
|
||||
const { newOffset } = decodeString(keyData, 0);
|
||||
userKeyPortion = keyData.subarray(newOffset);
|
||||
} else {
|
||||
// For other key types, extract everything after the key type
|
||||
const { newOffset } = decodeString(keyData, 0);
|
||||
userKeyPortion = keyData.subarray(newOffset);
|
||||
}
|
||||
|
||||
// Build the certificate body (to be signed)
|
||||
const certBody = Buffer.concat([
|
||||
encodeString(certTypeString),
|
||||
encodeString(nonce),
|
||||
userKeyPortion,
|
||||
encodeUInt64(serial),
|
||||
encodeUInt32(certType),
|
||||
encodeString(options.keyId),
|
||||
buildPrincipals(options.validPrincipals),
|
||||
encodeUInt64(validAfter),
|
||||
encodeUInt64(validBefore),
|
||||
buildCriticalOptions(criticalOptions),
|
||||
buildExtensions(extensions),
|
||||
encodeString(""), // reserved
|
||||
encodeString(caPubKeyBlob) // signature key (CA public key)
|
||||
]);
|
||||
|
||||
// Sign the certificate body
|
||||
const signature = crypto.sign(null, certBody, caPrivKey);
|
||||
|
||||
// Build the full signature blob (algorithm + signature)
|
||||
const signatureBlob = Buffer.concat([
|
||||
encodeString("ssh-ed25519"),
|
||||
encodeString(signature)
|
||||
]);
|
||||
|
||||
// Build complete certificate
|
||||
const certificate = Buffer.concat([certBody, encodeString(signatureBlob)]);
|
||||
|
||||
// Format as OpenSSH certificate line
|
||||
const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`;
|
||||
|
||||
return {
|
||||
certificate: certLine,
|
||||
certType: certTypeString,
|
||||
serial,
|
||||
keyId: options.keyId,
|
||||
validPrincipals: options.validPrincipals,
|
||||
validAfter: new Date(Number(validAfter) * 1000),
|
||||
validBefore: new Date(Number(validBefore) * 1000)
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
|
||||
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { approvals, db, type Approval } from "@server/db";
|
||||
import { eq, sql, and } from "drizzle-orm";
|
||||
import { eq, sql, and, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
@@ -88,7 +88,7 @@ export async function countApprovals(
|
||||
.where(
|
||||
and(
|
||||
eq(approvals.orgId, orgId),
|
||||
sql`${approvals.decision} in ${state}`
|
||||
inArray(approvals.decision, state)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
currentFingerprint,
|
||||
type Approval
|
||||
} from "@server/db";
|
||||
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
|
||||
import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
|
||||
@@ -37,18 +37,26 @@ const paramsSchema = z.strictObject({
|
||||
});
|
||||
|
||||
const querySchema = z.strictObject({
|
||||
limit: z
|
||||
.string()
|
||||
limit: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20),
|
||||
cursorPending: z.coerce // pending cursor
|
||||
.number<string>()
|
||||
.int()
|
||||
.max(1) // 0 means non pending
|
||||
.min(0) // 1 means pending
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
.catch(undefined),
|
||||
cursorTimestamp: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
approvalState: z
|
||||
.enum(["pending", "approved", "denied", "all"])
|
||||
.optional()
|
||||
@@ -61,13 +69,21 @@ const querySchema = z.strictObject({
|
||||
.pipe(z.number().int().positive().optional())
|
||||
});
|
||||
|
||||
async function queryApprovals(
|
||||
orgId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
approvalState: z.infer<typeof querySchema>["approvalState"],
|
||||
clientId?: number
|
||||
) {
|
||||
async function queryApprovals({
|
||||
orgId,
|
||||
limit,
|
||||
approvalState,
|
||||
cursorPending,
|
||||
cursorTimestamp,
|
||||
clientId
|
||||
}: {
|
||||
orgId: string;
|
||||
limit: number;
|
||||
approvalState: z.infer<typeof querySchema>["approvalState"];
|
||||
cursorPending?: number;
|
||||
cursorTimestamp?: number;
|
||||
clientId?: number;
|
||||
}) {
|
||||
let state: Array<Approval["decision"]> = [];
|
||||
switch (approvalState) {
|
||||
case "pending":
|
||||
@@ -83,6 +99,26 @@ async function queryApprovals(
|
||||
state = ["approved", "denied", "pending"];
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(approvals.orgId, orgId),
|
||||
sql`${approvals.decision} in ${state}`
|
||||
];
|
||||
|
||||
if (clientId) {
|
||||
conditions.push(eq(approvals.clientId, clientId));
|
||||
}
|
||||
|
||||
const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`;
|
||||
|
||||
if (cursorPending != null && cursorTimestamp != null) {
|
||||
// https://stackoverflow.com/a/79720298/10322846
|
||||
// composite cursor, next data means (pending, timestamp) <= cursor
|
||||
conditions.push(
|
||||
lte(pendingSortKey, cursorPending),
|
||||
lte(approvals.timestamp, cursorTimestamp)
|
||||
);
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.select({
|
||||
approvalId: approvals.approvalId,
|
||||
@@ -105,7 +141,8 @@ async function queryApprovals(
|
||||
fingerprintArch: currentFingerprint.arch,
|
||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||
fingerprintUsername: currentFingerprint.username,
|
||||
fingerprintHostname: currentFingerprint.hostname
|
||||
fingerprintHostname: currentFingerprint.hostname,
|
||||
timestamp: approvals.timestamp
|
||||
})
|
||||
.from(approvals)
|
||||
.innerJoin(users, and(eq(approvals.userId, users.userId)))
|
||||
@@ -118,22 +155,12 @@ async function queryApprovals(
|
||||
)
|
||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
||||
.where(
|
||||
and(
|
||||
eq(approvals.orgId, orgId),
|
||||
sql`${approvals.decision} in ${state}`,
|
||||
...(clientId ? [eq(approvals.clientId, clientId)] : [])
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`,
|
||||
desc(approvals.timestamp)
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(pendingSortKey), desc(approvals.timestamp))
|
||||
.limit(limit + 1); // the `+1` is used for the cursor
|
||||
|
||||
// Process results to format device names and build fingerprint objects
|
||||
return res.map((approval) => {
|
||||
const approvalsList = res.slice(0, limit).map((approval) => {
|
||||
const model = approval.deviceModel || null;
|
||||
const deviceName = approval.clientName
|
||||
? getUserDeviceName(model, approval.clientName)
|
||||
@@ -152,15 +179,15 @@ async function queryApprovals(
|
||||
|
||||
const fingerprint = hasFingerprintData
|
||||
? {
|
||||
platform: approval.fingerprintPlatform || null,
|
||||
osVersion: approval.fingerprintOsVersion || null,
|
||||
kernelVersion: approval.fingerprintKernelVersion || null,
|
||||
arch: approval.fingerprintArch || null,
|
||||
deviceModel: approval.deviceModel || null,
|
||||
serialNumber: approval.fingerprintSerialNumber || null,
|
||||
username: approval.fingerprintUsername || null,
|
||||
hostname: approval.fingerprintHostname || null
|
||||
}
|
||||
platform: approval.fingerprintPlatform ?? null,
|
||||
osVersion: approval.fingerprintOsVersion ?? null,
|
||||
kernelVersion: approval.fingerprintKernelVersion ?? null,
|
||||
arch: approval.fingerprintArch ?? null,
|
||||
deviceModel: approval.deviceModel ?? null,
|
||||
serialNumber: approval.fingerprintSerialNumber ?? null,
|
||||
username: approval.fingerprintUsername ?? null,
|
||||
hostname: approval.fingerprintHostname ?? null
|
||||
}
|
||||
: null;
|
||||
|
||||
const {
|
||||
@@ -183,11 +210,30 @@ async function queryApprovals(
|
||||
niceId: approval.niceId || null
|
||||
};
|
||||
});
|
||||
let nextCursorPending: number | null = null;
|
||||
let nextCursorTimestamp: number | null = null;
|
||||
if (res.length > limit) {
|
||||
const lastItem = res[limit];
|
||||
nextCursorPending = lastItem.decision === "pending" ? 1 : 0;
|
||||
nextCursorTimestamp = lastItem.timestamp;
|
||||
}
|
||||
return {
|
||||
approvalsList,
|
||||
nextCursorPending,
|
||||
nextCursorTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
export type ListApprovalsResponse = {
|
||||
approvals: NonNullable<Awaited<ReturnType<typeof queryApprovals>>>;
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
approvals: NonNullable<
|
||||
Awaited<ReturnType<typeof queryApprovals>>
|
||||
>["approvalsList"];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
cursorPending: number | null;
|
||||
cursorTimestamp: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export async function listApprovals(
|
||||
@@ -215,17 +261,25 @@ export async function listApprovals(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset, approvalState, clientId } = parsedQuery.data;
|
||||
const {
|
||||
limit,
|
||||
cursorPending,
|
||||
cursorTimestamp,
|
||||
approvalState,
|
||||
clientId
|
||||
} = parsedQuery.data;
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const approvalsList = await queryApprovals(
|
||||
orgId.toString(),
|
||||
limit,
|
||||
offset,
|
||||
approvalState,
|
||||
clientId
|
||||
);
|
||||
const { approvalsList, nextCursorPending, nextCursorTimestamp } =
|
||||
await queryApprovals({
|
||||
orgId: orgId.toString(),
|
||||
limit,
|
||||
cursorPending,
|
||||
cursorTimestamp,
|
||||
approvalState,
|
||||
clientId
|
||||
});
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -237,7 +291,8 @@ export async function listApprovals(
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
offset
|
||||
cursorPending: nextCursorPending,
|
||||
cursorTimestamp: nextCursorTimestamp
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -15,7 +15,19 @@ import { SubscriptionType } from "./hooks/getSubType";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import logger from "@server/logger";
|
||||
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
loginPage,
|
||||
loginPageBranding,
|
||||
loginPageBrandingOrg,
|
||||
loginPageOrg,
|
||||
orgs,
|
||||
resources,
|
||||
roles,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
@@ -59,10 +71,7 @@ async function capRetentionDays(
|
||||
}
|
||||
|
||||
// Get current org settings
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
||||
|
||||
if (!org) {
|
||||
logger.warn(`Org ${orgId} not found when capping retention days`);
|
||||
@@ -110,18 +119,13 @@ async function capRetentionDays(
|
||||
|
||||
// Apply updates if needed
|
||||
if (needsUpdate) {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set(updates)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
await db.update(orgs).set(updates).where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(
|
||||
`Successfully capped retention days for org ${orgId} to max ${maxRetentionDays} days`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`No retention day capping needed for org ${orgId}`
|
||||
);
|
||||
logger.debug(`No retention day capping needed for org ${orgId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +138,35 @@ export async function handleTierChange(
|
||||
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
|
||||
);
|
||||
|
||||
// Get all orgs that have this orgId as their billingOrgId
|
||||
const associatedOrgs = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.billingOrgId, orgId));
|
||||
|
||||
logger.info(
|
||||
`Found ${associatedOrgs.length} org(s) associated with billing org ${orgId}`
|
||||
);
|
||||
|
||||
// Loop over all associated orgs and apply tier changes
|
||||
for (const org of associatedOrgs) {
|
||||
await handleTierChangeForOrg(org.orgId, newTier, previousTier);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Completed tier change handling for all orgs associated with billing org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleTierChangeForOrg(
|
||||
orgId: string,
|
||||
newTier: SubscriptionType | null,
|
||||
previousTier?: SubscriptionType | null
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
|
||||
);
|
||||
|
||||
// License subscriptions are handled separately and don't use the tier matrix
|
||||
if (newTier === "license") {
|
||||
logger.debug(
|
||||
@@ -254,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`
|
||||
@@ -283,6 +320,12 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
|
||||
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableSshPam(orgId: string): Promise<void> {
|
||||
logger.info(
|
||||
`Disabled SSH PAM options on all roles and site resources for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
async function disableLoginPageBranding(orgId: string): Promise<void> {
|
||||
const [existingBranding] = await db
|
||||
.select()
|
||||
@@ -314,9 +357,7 @@ async function disableLoginPageDomain(orgId: string): Promise<void> {
|
||||
);
|
||||
|
||||
if (existingLoginPage) {
|
||||
await db
|
||||
.delete(loginPageOrg)
|
||||
.where(eq(loginPageOrg.orgId, orgId));
|
||||
await db.delete(loginPageOrg).where(eq(loginPageOrg.orgId, orgId));
|
||||
|
||||
await db
|
||||
.delete(loginPage)
|
||||
|
||||
@@ -112,11 +112,13 @@ export async function getOrgSubscriptionsData(
|
||||
throw new Error(`Not found`);
|
||||
}
|
||||
|
||||
const billingOrgId = org[0].billingOrgId || org[0].orgId;
|
||||
|
||||
// Get customer for org
|
||||
const customer = await db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.orgId, orgId))
|
||||
.where(eq(customers.orgId, billingOrgId))
|
||||
.limit(1);
|
||||
|
||||
const subscriptionsWithItems: Array<{
|
||||
|
||||
@@ -85,10 +85,14 @@ export async function getOrgUsage(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES
|
||||
);
|
||||
const egressData = await usageService.getUsage(
|
||||
const organizations = await usageService.getUsage(
|
||||
orgId,
|
||||
FeatureId.EGRESS_DATA_MB
|
||||
FeatureId.ORGINIZATIONS
|
||||
);
|
||||
// const egressData = await usageService.getUsage(
|
||||
// orgId,
|
||||
// FeatureId.EGRESS_DATA_MB
|
||||
// );
|
||||
|
||||
if (sites) {
|
||||
usageData.push(sites);
|
||||
@@ -96,15 +100,18 @@ export async function getOrgUsage(
|
||||
if (users) {
|
||||
usageData.push(users);
|
||||
}
|
||||
if (egressData) {
|
||||
usageData.push(egressData);
|
||||
}
|
||||
// if (egressData) {
|
||||
// usageData.push(egressData);
|
||||
// }
|
||||
if (domains) {
|
||||
usageData.push(domains);
|
||||
}
|
||||
if (remoteExitNodes) {
|
||||
usageData.push(remoteExitNodes);
|
||||
}
|
||||
if (organizations) {
|
||||
usageData.push(organizations);
|
||||
}
|
||||
|
||||
const orgLimits = await db
|
||||
.select()
|
||||
|
||||
@@ -25,6 +25,7 @@ import * as logs from "#private/routers/auditLogs";
|
||||
import * as misc from "#private/routers/misc";
|
||||
import * as reKey from "#private/routers/re-key";
|
||||
import * as approval from "#private/routers/approvals";
|
||||
import * as ssh from "#private/routers/ssh";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
@@ -506,3 +507,14 @@ authenticated.put(
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateExitNodeSecret
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/ssh/sign-key",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.sshPam),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.signSshKey),
|
||||
logActionAudit(ActionsEnum.signSshKey),
|
||||
ssh.signSshKey
|
||||
);
|
||||
|
||||
@@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
|
||||
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
|
||||
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -63,7 +64,10 @@ export async function generateNewEnterpriseLicense(
|
||||
|
||||
const licenseData = req.body;
|
||||
|
||||
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") {
|
||||
if (
|
||||
licenseData.tier != "big_license" &&
|
||||
licenseData.tier != "small_license"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -79,7 +83,8 @@ export async function generateNewEnterpriseLicense(
|
||||
return next(
|
||||
createHttpError(
|
||||
apiResponse.status || HttpCode.BAD_REQUEST,
|
||||
apiResponse.message || "Failed to create license from Fossorial API"
|
||||
apiResponse.message ||
|
||||
"Failed to create license from Fossorial API"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -112,7 +117,10 @@ export async function generateNewEnterpriseLicense(
|
||||
);
|
||||
}
|
||||
|
||||
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
|
||||
const tier =
|
||||
licenseData.tier === "big_license"
|
||||
? LicenseId.BIG_LICENSE
|
||||
: LicenseId.SMALL_LICENSE;
|
||||
const tierPrice = getLicensePriceSet()[tier];
|
||||
|
||||
const session = await stripe!.checkout.sessions.create({
|
||||
@@ -122,7 +130,7 @@ export async function generateNewEnterpriseLicense(
|
||||
{
|
||||
price: tierPrice, // Use the standard tier
|
||||
quantity: 1
|
||||
},
|
||||
}
|
||||
], // Start with the standard feature set that matches the free limits
|
||||
customer: customer.customerId,
|
||||
mode: "subscription",
|
||||
|
||||
@@ -26,6 +26,7 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq, InferInsertModel } from "drizzle-orm";
|
||||
import { build } from "@server/build";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import config from "#private/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
@@ -37,14 +38,36 @@ const bodySchema = z.strictObject({
|
||||
.union([
|
||||
z.literal(""),
|
||||
z
|
||||
.url("Must be a valid URL")
|
||||
.superRefine(async (url, ctx) => {
|
||||
.string()
|
||||
.superRefine(async (urlOrPath, ctx) => {
|
||||
const parseResult = z.url().safeParse(urlOrPath);
|
||||
if (!parseResult.success) {
|
||||
if (build !== "enterprise") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be a valid URL"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
validateLocalPath(urlOrPath);
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||
});
|
||||
} finally {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(urlOrPath, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(url, { method: "GET" });
|
||||
return fetch(urlOrPath, { method: "GET" });
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
|
||||
@@ -12,7 +12,14 @@
|
||||
*/
|
||||
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
exitNodes,
|
||||
exitNodeOrgs,
|
||||
ExitNode,
|
||||
ExitNodeOrg,
|
||||
orgs
|
||||
} from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { z } from "zod";
|
||||
import { remoteExitNodes } from "@server/db";
|
||||
@@ -25,7 +32,7 @@ import { createRemoteExitNodeSession } from "#private/auth/sessions/remoteExitNo
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { hashPassword, verifyPassword } from "@server/auth/password";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
@@ -169,7 +176,17 @@ export async function createRemoteExitNode(
|
||||
);
|
||||
}
|
||||
|
||||
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (!existingExitNode) {
|
||||
@@ -217,19 +234,43 @@ export async function createRemoteExitNode(
|
||||
});
|
||||
}
|
||||
|
||||
numExitNodeOrgs = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(eq(exitNodeOrgs.orgId, orgId));
|
||||
});
|
||||
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
|
||||
if (org.billingOrgId) {
|
||||
const otherBillingOrgs = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(
|
||||
and(
|
||||
eq(orgs.billingOrgId, org.billingOrgId),
|
||||
ne(orgs.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (numExitNodeOrgs) {
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
numExitNodeOrgs.length
|
||||
);
|
||||
}
|
||||
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||
|
||||
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
exitNodeOrgs.exitNodeId,
|
||||
existingExitNode.exitNodeId
|
||||
),
|
||||
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
||||
await usageService.add(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
1,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const token = generateSessionToken();
|
||||
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, ExitNodeOrg, exitNodeOrgs, exitNodes } from "@server/db";
|
||||
import { db, ExitNodeOrg, exitNodeOrgs, exitNodes, orgs } from "@server/db";
|
||||
import { remoteExitNodes } from "@server/db";
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import { and, count, eq, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -50,7 +50,8 @@ export async function deleteRemoteExitNode(
|
||||
const [remoteExitNode] = await db
|
||||
.select()
|
||||
.from(remoteExitNodes)
|
||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
|
||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
||||
.limit(1);
|
||||
|
||||
if (!remoteExitNode) {
|
||||
return next(
|
||||
@@ -70,7 +71,17 @@ export async function deleteRemoteExitNode(
|
||||
);
|
||||
}
|
||||
|
||||
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
|
||||
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Org with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(exitNodeOrgs)
|
||||
@@ -81,38 +92,39 @@ export async function deleteRemoteExitNode(
|
||||
)
|
||||
);
|
||||
|
||||
const [remainingExitNodeOrgs] = await trx
|
||||
.select({ count: count() })
|
||||
.from(exitNodeOrgs)
|
||||
.where(eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId!));
|
||||
// calculate if the user is in any other of the orgs before we count it as an remove to the billing org
|
||||
if (org.billingOrgId) {
|
||||
const otherBillingOrgs = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.billingOrgId, org.billingOrgId));
|
||||
|
||||
if (remainingExitNodeOrgs.count === 0) {
|
||||
await trx
|
||||
.delete(remoteExitNodes)
|
||||
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||
|
||||
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(
|
||||
eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)
|
||||
and(
|
||||
eq(
|
||||
exitNodeOrgs.exitNodeId,
|
||||
remoteExitNode.exitNodeId!
|
||||
),
|
||||
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
||||
)
|
||||
);
|
||||
await trx
|
||||
.delete(exitNodes)
|
||||
.where(
|
||||
eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId!)
|
||||
|
||||
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
||||
await usageService.add(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
-1,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
numExitNodeOrgs = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(eq(exitNodeOrgs.orgId, orgId));
|
||||
});
|
||||
|
||||
if (numExitNodeOrgs) {
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
numExitNodeOrgs.length
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
||||
14
server/private/routers/ssh/index.ts
Normal file
14
server/private/routers/ssh/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./signSshKey";
|
||||
454
server/private/routers/ssh/signSshKey.ts
Normal file
454
server/private/routers/ssh/signSshKey.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
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";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq, or, and } from "drizzle-orm";
|
||||
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
|
||||
import config from "@server/lib/config";
|
||||
import { sendToClient } from "#private/routers/ws";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
publicKey: z.string().nonempty(),
|
||||
resourceId: z.number().int().positive().optional(),
|
||||
resource: z.string().nonempty().optional() // this is either the nice id or the alias
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const fields = [data.resourceId, data.resource];
|
||||
const definedFields = fields.filter((field) => field !== undefined);
|
||||
return definedFields.length === 1;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Exactly one of resourceId, niceId, or alias must be provided"
|
||||
}
|
||||
);
|
||||
|
||||
export type SignSshKeyResponse = {
|
||||
certificate: string;
|
||||
messageId: number;
|
||||
sshUsername: string;
|
||||
sshHost: string;
|
||||
resourceId: number;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: string;
|
||||
validBefore: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
// registry.registerPath({
|
||||
// method: "post",
|
||||
// path: "/org/{orgId}/ssh/sign-key",
|
||||
// description: "Sign an SSH public key for access to a resource.",
|
||||
// tags: [OpenAPITags.Org, OpenAPITags.Ssh],
|
||||
// request: {
|
||||
// params: paramsSchema,
|
||||
// body: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: bodySchema
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// responses: {}
|
||||
// });
|
||||
|
||||
export async function signSshKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const {
|
||||
publicKey,
|
||||
resourceId,
|
||||
resource: resourceQueryString
|
||||
} = parsedBody.data;
|
||||
const userId = req.user?.userId;
|
||||
const roleId = req.userOrgRoleId!;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
const [userOrg] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!userOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not belong to the specified organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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]
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Unable to extract username from email"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (req.user?.username) {
|
||||
usernameToUse = req.user.username;
|
||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username is not valid for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User does not have a valid email or username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// check if we have a existing user in this org with the same
|
||||
const [existingUserWithSameName] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, usernameToUse)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUserWithSameName) {
|
||||
let foundUniqueUsername = false;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, candidateUsername)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
usernameToUse = candidateUsername;
|
||||
foundUniqueUsername = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundUniqueUsername) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Unable to generate a unique username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
}
|
||||
|
||||
// Get and decrypt the org's CA keys
|
||||
const caKeys = await getOrgCAKeys(
|
||||
orgId,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
if (!caKeys) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"SSH CA not configured for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the resource exists and belongs to the org
|
||||
// Build the where clause dynamically based on which field is provided
|
||||
let whereClause;
|
||||
if (resourceId !== undefined) {
|
||||
whereClause = eq(siteResources.siteResourceId, resourceId);
|
||||
} else if (resourceQueryString !== undefined) {
|
||||
whereClause = or(
|
||||
eq(siteResources.niceId, resourceQueryString),
|
||||
eq(siteResources.alias, resourceQueryString)
|
||||
);
|
||||
} else {
|
||||
// This should never happen due to the schema validation, but TypeScript doesn't know that
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"One of resourceId, niceId, or alias must be provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(whereClause, eq(siteResources.orgId, orgId)));
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Resource not found`)
|
||||
);
|
||||
}
|
||||
|
||||
if (resources.length > 1) {
|
||||
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Multiple resources found matching the criteria`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resource = resources[0];
|
||||
|
||||
if (resource.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Resource does not belong to the specified organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the user has access to the resource
|
||||
const hasAccess = await canUserAccessSiteResource({
|
||||
userId: userId,
|
||||
resourceId: resource.siteResourceId,
|
||||
roleId: roleId
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, resource.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
// only valid for 5 minutes
|
||||
const validFor = 300n;
|
||||
|
||||
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||
validPrincipals: [usernameToUse, resource.niceId],
|
||||
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||
validBefore: now + validFor
|
||||
});
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
externalAuthDaemon: resource.authDaemonMode === "remote",
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const expiresIn = Number(validFor); // seconds
|
||||
|
||||
let sshHost;
|
||||
if (resource.alias && resource.alias != "") {
|
||||
sshHost = resource.alias;
|
||||
} else {
|
||||
sshHost = resource.destination;
|
||||
}
|
||||
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
messageId: message.messageId,
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost,
|
||||
resourceId: resource.siteResourceId,
|
||||
keyId: cert.keyId,
|
||||
validPrincipals: cert.validPrincipals,
|
||||
validAfter: cert.validAfter.toISOString(),
|
||||
validBefore: cert.validBefore.toISOString(),
|
||||
expiresIn
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "SSH key signed successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error signing SSH key:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while signing the SSH key"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { verifyTotpCode } from "@server/auth/totp";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { build } from "@server/build";
|
||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||
import {
|
||||
deleteOrgById,
|
||||
sendTerminationMessages
|
||||
@@ -40,11 +42,6 @@ export type DeleteMyAccountSuccessResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Self-service account deletion (saas only). Returns preview when no password;
|
||||
* requires password and optional 2FA code to perform deletion. Uses shared
|
||||
* deleteOrgById for each owned org (delete-my-account may delete multiple orgs).
|
||||
*/
|
||||
export async function deleteMyAccount(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -91,18 +88,35 @@ export async function deleteMyAccount(
|
||||
|
||||
const ownedOrgsRows = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId
|
||||
orgId: userOrgs.orgId,
|
||||
isOwner: userOrgs.isOwner,
|
||||
isBillingOrg: orgs.isBillingOrg
|
||||
})
|
||||
.from(userOrgs)
|
||||
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.isOwner, true)
|
||||
)
|
||||
and(eq(userOrgs.userId, userId), eq(userOrgs.isOwner, true))
|
||||
);
|
||||
|
||||
const orgIds = ownedOrgsRows.map((r) => r.orgId);
|
||||
|
||||
if (build === "saas" && orgIds.length > 0) {
|
||||
const primaryOrgId = ownedOrgsRows.find(
|
||||
(r) => r.isBillingOrg && r.isOwner
|
||||
)?.orgId;
|
||||
if (primaryOrgId) {
|
||||
const { tier, active } = await getOrgTierData(primaryOrgId);
|
||||
if (active && tier) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"You must cancel your subscription before deleting your account"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
const orgsWithNames =
|
||||
orgIds.length > 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { db, users } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { z } from "zod";
|
||||
import { email, z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import createHttpError from "http-errors";
|
||||
import response from "@server/lib/response";
|
||||
@@ -21,7 +21,6 @@ import { hashPassword } from "@server/auth/password";
|
||||
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
|
||||
import { build } from "@server/build";
|
||||
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
|
||||
|
||||
@@ -31,7 +30,8 @@ export const signupBodySchema = z.object({
|
||||
inviteToken: z.string().optional(),
|
||||
inviteId: z.string().optional(),
|
||||
termsAcceptedTimestamp: z.string().nullable().optional(),
|
||||
marketingEmailConsent: z.boolean().optional()
|
||||
marketingEmailConsent: z.boolean().optional(),
|
||||
skipVerificationEmail: z.boolean().optional()
|
||||
});
|
||||
|
||||
export type SignUpBody = z.infer<typeof signupBodySchema>;
|
||||
@@ -62,7 +62,8 @@ export async function signup(
|
||||
inviteToken,
|
||||
inviteId,
|
||||
termsAcceptedTimestamp,
|
||||
marketingEmailConsent
|
||||
marketingEmailConsent,
|
||||
skipVerificationEmail
|
||||
} = parsedBody.data;
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
@@ -198,26 +199,6 @@ export async function signup(
|
||||
// orgId: null,
|
||||
// });
|
||||
|
||||
if (build == "saas") {
|
||||
const { success, error, org } = await createUserAccountOrg(
|
||||
userId,
|
||||
email
|
||||
);
|
||||
if (!success) {
|
||||
if (error) {
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error)
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create user account and organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
const sess = await createSession(token, userId);
|
||||
const isSecure = req.protocol === "https";
|
||||
@@ -235,7 +216,13 @@ export async function signup(
|
||||
}
|
||||
|
||||
if (config.getRawConfig().flags?.require_email_verification) {
|
||||
sendEmailVerificationCode(email, userId);
|
||||
if (!skipVerificationEmail) {
|
||||
sendEmailVerificationCode(email, userId);
|
||||
} else {
|
||||
logger.debug(
|
||||
`User ${email} opted out of verification email during signup.`
|
||||
);
|
||||
}
|
||||
|
||||
return response<SignUpResponse>(res, {
|
||||
data: {
|
||||
@@ -243,7 +230,9 @@ export async function signup(
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: `User created successfully. We sent an email to ${email} with a verification code.`,
|
||||
message: skipVerificationEmail
|
||||
? "User created successfully. Please verify your email."
|
||||
: `User created successfully. We sent an email to ${email} with a verification code.`,
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./unarchiveClient";
|
||||
export * from "./blockClient";
|
||||
export * from "./unblockClient";
|
||||
export * from "./listClients";
|
||||
export * from "./listUserDevices";
|
||||
export * from "./updateClient";
|
||||
export * from "./getClient";
|
||||
export * from "./createUserClient";
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
import { db, olms, users } from "@server/db";
|
||||
import {
|
||||
clients,
|
||||
clientSitesAssociationsCache,
|
||||
currentFingerprint,
|
||||
db,
|
||||
olms,
|
||||
orgs,
|
||||
roleClients,
|
||||
sites,
|
||||
userClients,
|
||||
clientSitesAssociationsCache,
|
||||
currentFingerprint
|
||||
users
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import {
|
||||
and,
|
||||
count,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
or,
|
||||
sql
|
||||
sql,
|
||||
type SQL
|
||||
} from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import NodeCache from "node-cache";
|
||||
import semver from "semver";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||
|
||||
@@ -89,38 +93,86 @@ const listClientsParamsSchema = z.strictObject({
|
||||
});
|
||||
|
||||
const listClientsSchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
filter: z.enum(["user", "machine"]).optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["megabytesIn", "megabytesOut"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["megabytesIn", "megabytesOut"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
.enum(["asc", "desc"])
|
||||
.optional()
|
||||
.default("asc")
|
||||
.catch("asc")
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
online: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter by online status"
|
||||
}),
|
||||
status: z.preprocess(
|
||||
(val: string | undefined) => {
|
||||
if (val) {
|
||||
return val.split(","); // the search query array is an array joined by commas
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
z
|
||||
.array(z.enum(["active", "blocked", "archived"]))
|
||||
.optional()
|
||||
.default(["active"])
|
||||
.catch(["active"])
|
||||
.openapi({
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: ["active", "blocked", "archived"]
|
||||
},
|
||||
default: ["active"],
|
||||
description:
|
||||
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
function queryClients(
|
||||
orgId: string,
|
||||
accessibleClientIds: number[],
|
||||
filter?: "user" | "machine"
|
||||
) {
|
||||
const conditions = [
|
||||
inArray(clients.clientId, accessibleClientIds),
|
||||
eq(clients.orgId, orgId)
|
||||
];
|
||||
|
||||
// Add filter condition based on filter type
|
||||
if (filter === "user") {
|
||||
conditions.push(isNotNull(clients.userId));
|
||||
} else if (filter === "machine") {
|
||||
conditions.push(isNull(clients.userId));
|
||||
}
|
||||
|
||||
function queryClientsBase() {
|
||||
return db
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
@@ -142,22 +194,13 @@ function queryClients(
|
||||
approvalState: clients.approvalState,
|
||||
olmArchived: olms.archived,
|
||||
archived: clients.archived,
|
||||
blocked: clients.blocked,
|
||||
deviceModel: currentFingerprint.deviceModel,
|
||||
fingerprintPlatform: currentFingerprint.platform,
|
||||
fingerprintOsVersion: currentFingerprint.osVersion,
|
||||
fingerprintKernelVersion: currentFingerprint.kernelVersion,
|
||||
fingerprintArch: currentFingerprint.arch,
|
||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||
fingerprintUsername: currentFingerprint.username,
|
||||
fingerprintHostname: currentFingerprint.hostname
|
||||
blocked: clients.blocked
|
||||
})
|
||||
.from(clients)
|
||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||
.leftJoin(users, eq(clients.userId, users.userId))
|
||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
||||
.where(and(...conditions));
|
||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
|
||||
}
|
||||
|
||||
async function getSiteAssociations(clientIds: number[]) {
|
||||
@@ -175,7 +218,7 @@ async function getSiteAssociations(clientIds: number[]) {
|
||||
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
||||
}
|
||||
|
||||
type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
||||
type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
||||
sites: Array<{
|
||||
siteId: number;
|
||||
siteName: string | null;
|
||||
@@ -186,10 +229,9 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
||||
|
||||
type OlmWithUpdateAvailable = ClientWithSites;
|
||||
|
||||
export type ListClientsResponse = {
|
||||
export type ListClientsResponse = PaginatedResponse<{
|
||||
clients: Array<ClientWithSites>;
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
}>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
@@ -218,7 +260,8 @@ export async function listClients(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset, filter } = parsedQuery.data;
|
||||
const { page, pageSize, online, query, status, sort_by, order } =
|
||||
parsedQuery.data;
|
||||
|
||||
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -267,28 +310,73 @@ export async function listClients(
|
||||
const accessibleClientIds = accessibleClients.map(
|
||||
(client) => client.clientId
|
||||
);
|
||||
const baseQuery = queryClients(orgId, accessibleClientIds, filter);
|
||||
|
||||
// Get client count with filter
|
||||
const countConditions = [
|
||||
inArray(clients.clientId, accessibleClientIds),
|
||||
eq(clients.orgId, orgId)
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(clients.clientId, accessibleClientIds),
|
||||
eq(clients.orgId, orgId),
|
||||
isNull(clients.userId)
|
||||
)
|
||||
];
|
||||
|
||||
if (filter === "user") {
|
||||
countConditions.push(isNotNull(clients.userId));
|
||||
} else if (filter === "machine") {
|
||||
countConditions.push(isNull(clients.userId));
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(clients.online, online));
|
||||
}
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: count() })
|
||||
.from(clients)
|
||||
.where(and(...countConditions));
|
||||
if (status.length > 0) {
|
||||
const filterAggregates: (SQL<unknown> | undefined)[] = [];
|
||||
|
||||
const clientsList = await baseQuery.limit(limit).offset(offset);
|
||||
const totalCountResult = await countQuery;
|
||||
const totalCount = totalCountResult[0].count;
|
||||
if (status.includes("active")) {
|
||||
filterAggregates.push(
|
||||
and(eq(clients.archived, false), eq(clients.blocked, false))
|
||||
);
|
||||
}
|
||||
|
||||
if (status.includes("archived")) {
|
||||
filterAggregates.push(eq(clients.archived, true));
|
||||
}
|
||||
if (status.includes("blocked")) {
|
||||
filterAggregates.push(eq(clients.blocked, true));
|
||||
}
|
||||
|
||||
conditions.push(or(...filterAggregates));
|
||||
}
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${clients.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${clients.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||
|
||||
const listMachinesQuery = baseQuery
|
||||
.limit(page)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(
|
||||
sort_by
|
||||
? order === "asc"
|
||||
? asc(clients[sort_by])
|
||||
: desc(clients[sort_by])
|
||||
: asc(clients.clientId)
|
||||
);
|
||||
|
||||
const [clientsList, totalCount] = await Promise.all([
|
||||
listMachinesQuery,
|
||||
countQuery
|
||||
]);
|
||||
|
||||
// Get associated sites for all clients
|
||||
const clientIds = clientsList.map((client) => client.clientId);
|
||||
@@ -319,14 +407,8 @@ export async function listClients(
|
||||
|
||||
// Merge clients with their site associations and replace name with device name
|
||||
const clientsWithSites = clientsList.map((client) => {
|
||||
const model = client.deviceModel || null;
|
||||
let newName = client.name;
|
||||
if (filter === "user") {
|
||||
newName = getUserDeviceName(model, client.name);
|
||||
}
|
||||
return {
|
||||
...client,
|
||||
name: newName,
|
||||
sites: sitesByClient[client.clientId] || []
|
||||
};
|
||||
});
|
||||
@@ -371,8 +453,8 @@ export async function listClients(
|
||||
clients: olmsWithUpdates,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
offset
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
|
||||
500
server/routers/client/listUserDevices.ts
Normal file
500
server/routers/client/listUserDevices.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
clients,
|
||||
currentFingerprint,
|
||||
db,
|
||||
olms,
|
||||
orgs,
|
||||
roleClients,
|
||||
userClients,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
or,
|
||||
sql,
|
||||
type SQL
|
||||
} from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import NodeCache from "node-cache";
|
||||
import semver from "semver";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||
|
||||
async function getLatestOlmVersion(): Promise<string | null> {
|
||||
try {
|
||||
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
|
||||
if (cachedVersion) {
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/fosrl/olm/tags",
|
||||
{
|
||||
signal: controller.signal
|
||||
}
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let tags = await response.json();
|
||||
if (!Array.isArray(tags) || tags.length === 0) {
|
||||
logger.warn("No tags found for Olm repository");
|
||||
return null;
|
||||
}
|
||||
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||
const latestVersion = tags[0].name;
|
||||
|
||||
olmVersionCache.set("latestOlmVersion", latestVersion);
|
||||
|
||||
return latestVersion;
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
|
||||
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
logger.warn("Connection timeout while fetching latest Olm version");
|
||||
} else {
|
||||
logger.warn(
|
||||
"Error fetching latest Olm version:",
|
||||
error.message || error
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const listUserDevicesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listUserDevicesSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["megabytesIn", "megabytesOut"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["megabytesIn", "megabytesOut"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
.enum(["asc", "desc"])
|
||||
.optional()
|
||||
.default("asc")
|
||||
.catch("asc")
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
online: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter by online status"
|
||||
}),
|
||||
agent: z
|
||||
.enum([
|
||||
"windows",
|
||||
"android",
|
||||
"cli",
|
||||
"olm",
|
||||
"macos",
|
||||
"ios",
|
||||
"ipados",
|
||||
"unknown"
|
||||
])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: [
|
||||
"windows",
|
||||
"android",
|
||||
"cli",
|
||||
"olm",
|
||||
"macos",
|
||||
"ios",
|
||||
"ipados",
|
||||
"unknown"
|
||||
],
|
||||
description:
|
||||
"Filter by agent type. Use 'unknown' to filter clients with no agent detected."
|
||||
}),
|
||||
status: z.preprocess(
|
||||
(val: string | undefined) => {
|
||||
if (val) {
|
||||
return val.split(","); // the search query array is an array joined by commas
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
z
|
||||
.array(
|
||||
z.enum(["active", "pending", "denied", "blocked", "archived"])
|
||||
)
|
||||
.optional()
|
||||
.default(["active", "pending"])
|
||||
.catch(["active", "pending"])
|
||||
.openapi({
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: ["active", "pending", "denied", "blocked", "archived"]
|
||||
},
|
||||
default: ["active", "pending"],
|
||||
description:
|
||||
"Filter by device status. Can include multiple values separated by commas. 'active' means not archived, not blocked, and if approval is enabled, approved. 'pending' and 'denied' are only applicable if approval is enabled."
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
function queryUserDevicesBase() {
|
||||
return db
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
orgId: clients.orgId,
|
||||
name: clients.name,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet,
|
||||
megabytesIn: clients.megabytesIn,
|
||||
megabytesOut: clients.megabytesOut,
|
||||
orgName: orgs.name,
|
||||
type: clients.type,
|
||||
online: clients.online,
|
||||
olmVersion: olms.version,
|
||||
userId: clients.userId,
|
||||
username: users.username,
|
||||
userEmail: users.email,
|
||||
niceId: clients.niceId,
|
||||
agent: olms.agent,
|
||||
approvalState: clients.approvalState,
|
||||
olmArchived: olms.archived,
|
||||
archived: clients.archived,
|
||||
blocked: clients.blocked,
|
||||
deviceModel: currentFingerprint.deviceModel,
|
||||
fingerprintPlatform: currentFingerprint.platform,
|
||||
fingerprintOsVersion: currentFingerprint.osVersion,
|
||||
fingerprintKernelVersion: currentFingerprint.kernelVersion,
|
||||
fingerprintArch: currentFingerprint.arch,
|
||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||
fingerprintUsername: currentFingerprint.username,
|
||||
fingerprintHostname: currentFingerprint.hostname
|
||||
})
|
||||
.from(clients)
|
||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||
.leftJoin(users, eq(clients.userId, users.userId))
|
||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
|
||||
}
|
||||
|
||||
type OlmWithUpdateAvailable = Awaited<
|
||||
ReturnType<typeof queryUserDevicesBase>
|
||||
>[0] & {
|
||||
olmUpdateAvailable?: boolean;
|
||||
};
|
||||
|
||||
export type ListUserDevicesResponse = PaginatedResponse<{
|
||||
devices: Array<OlmWithUpdateAvailable>;
|
||||
}>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/user-devices",
|
||||
description: "List all user devices for an organization.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
query: listUserDevicesSchema,
|
||||
params: listUserDevicesParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listUserDevices(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listUserDevicesSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { page, pageSize, query, sort_by, online, status, agent, order } =
|
||||
parsedQuery.data;
|
||||
|
||||
const parsedParams = listUserDevicesParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let accessibleClients;
|
||||
if (req.user) {
|
||||
accessibleClients = await db
|
||||
.select({
|
||||
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
|
||||
})
|
||||
.from(userClients)
|
||||
.fullJoin(
|
||||
roleClients,
|
||||
eq(userClients.clientId, roleClients.clientId)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(userClients.userId, req.user!.userId),
|
||||
eq(roleClients.roleId, req.userOrgRoleId!)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
accessibleClients = await db
|
||||
.select({ clientId: clients.clientId })
|
||||
.from(clients)
|
||||
.where(eq(clients.orgId, orgId));
|
||||
}
|
||||
|
||||
const accessibleClientIds = accessibleClients.map(
|
||||
(client) => client.clientId
|
||||
);
|
||||
// Get client count with filter
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(clients.clientId, accessibleClientIds),
|
||||
eq(clients.orgId, orgId),
|
||||
isNotNull(clients.userId)
|
||||
)
|
||||
];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${clients.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${clients.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${users.email})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(clients.online, online));
|
||||
}
|
||||
|
||||
const agentValueMap = {
|
||||
windows: "Pangolin Windows",
|
||||
android: "Pangolin Android",
|
||||
ios: "Pangolin iOS",
|
||||
ipados: "Pangolin iPadOS",
|
||||
macos: "Pangolin macOS",
|
||||
cli: "Pangolin CLI",
|
||||
olm: "Olm CLI"
|
||||
} satisfies Record<
|
||||
Exclude<typeof agent, undefined | "unknown">,
|
||||
string
|
||||
>;
|
||||
if (typeof agent !== "undefined") {
|
||||
if (agent === "unknown") {
|
||||
conditions.push(isNull(olms.agent));
|
||||
} else {
|
||||
conditions.push(eq(olms.agent, agentValueMap[agent]));
|
||||
}
|
||||
}
|
||||
|
||||
if (status.length > 0) {
|
||||
const filterAggregates: (SQL<unknown> | undefined)[] = [];
|
||||
|
||||
if (status.includes("active")) {
|
||||
filterAggregates.push(
|
||||
and(
|
||||
eq(clients.archived, false),
|
||||
eq(clients.blocked, false),
|
||||
build !== "oss"
|
||||
? or(
|
||||
eq(clients.approvalState, "approved"),
|
||||
isNull(clients.approvalState) // approval state of `NULL` means approved by default
|
||||
)
|
||||
: undefined // undefined are automatically ignored by `drizzle-orm`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (status.includes("archived")) {
|
||||
filterAggregates.push(eq(clients.archived, true));
|
||||
}
|
||||
if (status.includes("blocked")) {
|
||||
filterAggregates.push(eq(clients.blocked, true));
|
||||
}
|
||||
|
||||
if (build !== "oss") {
|
||||
if (status.includes("pending")) {
|
||||
filterAggregates.push(eq(clients.approvalState, "pending"));
|
||||
}
|
||||
if (status.includes("denied")) {
|
||||
filterAggregates.push(eq(clients.approvalState, "denied"));
|
||||
}
|
||||
}
|
||||
|
||||
conditions.push(or(...filterAggregates));
|
||||
}
|
||||
|
||||
const baseQuery = queryUserDevicesBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||
|
||||
const listDevicesQuery = baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(
|
||||
sort_by
|
||||
? order === "asc"
|
||||
? asc(clients[sort_by])
|
||||
: desc(clients[sort_by])
|
||||
: asc(clients.clientId)
|
||||
);
|
||||
|
||||
const [clientsList, totalCount] = await Promise.all([
|
||||
listDevicesQuery,
|
||||
countQuery
|
||||
]);
|
||||
|
||||
// Merge clients with their site associations and replace name with device name
|
||||
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map(
|
||||
(client) => {
|
||||
const model = client.deviceModel || null;
|
||||
const newName = getUserDeviceName(model, client.name);
|
||||
const OlmWithUpdate: OlmWithUpdateAvailable = {
|
||||
...client,
|
||||
name: newName
|
||||
};
|
||||
// Initially set to false, will be updated if version check succeeds
|
||||
OlmWithUpdate.olmUpdateAvailable = false;
|
||||
return OlmWithUpdate;
|
||||
}
|
||||
);
|
||||
|
||||
// Try to get the latest version, but don't block if it fails
|
||||
try {
|
||||
const latestOlmVersion = await getLatestOlmVersion();
|
||||
|
||||
if (latestOlmVersion) {
|
||||
olmsWithUpdates.forEach((client) => {
|
||||
try {
|
||||
client.olmUpdateAvailable = semver.lt(
|
||||
client.olmVersion ? client.olmVersion : "",
|
||||
latestOlmVersion
|
||||
);
|
||||
} catch (error) {
|
||||
client.olmUpdateAvailable = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error but don't let it block the response
|
||||
logger.warn(
|
||||
"Failed to check for OLM updates, continuing without update info:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return response<ListUserDevicesResponse>(res, {
|
||||
data: {
|
||||
devices: olmsWithUpdates,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Clients retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -148,7 +148,6 @@ export async function createOrgDomain(
|
||||
}
|
||||
}
|
||||
|
||||
let numOrgDomains: OrgDomains[] | undefined;
|
||||
let aRecords: CreateDomainResponse["aRecords"];
|
||||
let cnameRecords: CreateDomainResponse["cnameRecords"];
|
||||
let txtRecords: CreateDomainResponse["txtRecords"];
|
||||
@@ -347,20 +346,9 @@ export async function createOrgDomain(
|
||||
await trx.insert(dnsRecords).values(recordsToInsert);
|
||||
}
|
||||
|
||||
numOrgDomains = await trx
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.orgId, orgId));
|
||||
await usageService.add(orgId, FeatureId.DOMAINS, 1, trx);
|
||||
});
|
||||
|
||||
if (numOrgDomains) {
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.DOMAINS,
|
||||
numOrgDomains.length
|
||||
);
|
||||
}
|
||||
|
||||
if (!returned) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
@@ -36,8 +36,6 @@ export async function deleteAccountDomain(
|
||||
}
|
||||
const { domainId, orgId } = parsed.data;
|
||||
|
||||
let numOrgDomains: OrgDomains[] | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const [existing] = await trx
|
||||
.select()
|
||||
@@ -79,20 +77,9 @@ export async function deleteAccountDomain(
|
||||
|
||||
await trx.delete(domains).where(eq(domains.domainId, domainId));
|
||||
|
||||
numOrgDomains = await trx
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.orgId, orgId));
|
||||
await usageService.add(orgId, FeatureId.DOMAINS, -1, trx);
|
||||
});
|
||||
|
||||
if (numOrgDomains) {
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.DOMAINS,
|
||||
numOrgDomains.length
|
||||
);
|
||||
}
|
||||
|
||||
return response<DeleteAccountDomainResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
|
||||
@@ -50,6 +50,7 @@ import createHttpError from "http-errors";
|
||||
import { build } from "@server/build";
|
||||
import { createStore } from "#dynamic/lib/rateLimitStore";
|
||||
import { logActionAudit } from "#dynamic/middlewares";
|
||||
import { checkRoundTripMessage } from "./ws";
|
||||
|
||||
// Root routes
|
||||
export const unauthenticated = Router();
|
||||
@@ -64,9 +65,8 @@ authenticated.use(verifySessionUserMiddleware);
|
||||
|
||||
authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
|
||||
authenticated.get("/org/checkId", org.checkId);
|
||||
if (build === "oss" || build === "enterprise") {
|
||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||
}
|
||||
|
||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||
|
||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
||||
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||
@@ -86,16 +86,14 @@ authenticated.post(
|
||||
org.updateOrg
|
||||
);
|
||||
|
||||
if (build !== "saas") {
|
||||
authenticated.delete(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
verifyUserIsOrgOwner,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||
logActionAudit(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg
|
||||
);
|
||||
}
|
||||
authenticated.delete(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
verifyUserIsOrgOwner,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||
logActionAudit(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/site",
|
||||
@@ -145,6 +143,13 @@ authenticated.get(
|
||||
client.listClients
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/user-devices",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listClients),
|
||||
client.listUserDevices
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/client/:clientId",
|
||||
verifyClientAccess,
|
||||
@@ -1116,6 +1121,8 @@ authenticated.get(
|
||||
blueprints.getBlueprint
|
||||
);
|
||||
|
||||
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
||||
|
||||
// Auth routes
|
||||
export const authRouter = Router();
|
||||
unauthenticated.use("/auth", authRouter);
|
||||
|
||||
@@ -197,7 +197,6 @@ export async function updateSiteBandwidth(
|
||||
usageService
|
||||
.checkLimitSet(
|
||||
orgId,
|
||||
|
||||
FeatureId.EGRESS_DATA_MB,
|
||||
bandwidthUsage
|
||||
)
|
||||
|
||||
@@ -70,6 +70,15 @@ export async function createIdpOrgPolicy(
|
||||
const { idpId, orgId } = parsedParams.data;
|
||||
const { roleMapping, orgMapping } = parsedBody.data;
|
||||
|
||||
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
|
||||
@@ -80,6 +80,17 @@ export async function createOidcIdp(
|
||||
tags
|
||||
} = parsedBody.data;
|
||||
|
||||
if (
|
||||
process.env.IDENTITY_PROVIDER_MODE === "org"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
|
||||
const encryptedSecret = encrypt(clientSecret, key);
|
||||
|
||||
@@ -69,6 +69,15 @@ export async function updateIdpOrgPolicy(
|
||||
const { idpId, orgId } = parsedParams.data;
|
||||
const { roleMapping, orgMapping } = parsedBody.data;
|
||||
|
||||
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if IDP and policy exist
|
||||
const [existing] = await db
|
||||
.select()
|
||||
|
||||
@@ -99,6 +99,15 @@ export async function updateOidcIdp(
|
||||
tags
|
||||
} = parsedBody.data;
|
||||
|
||||
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if IDP exists and is of type OIDC
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
|
||||
@@ -36,6 +36,10 @@ import { build } from "@server/build";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
assignUserToOrg,
|
||||
removeUserFromOrg
|
||||
} from "@server/lib/userOrg";
|
||||
|
||||
const ensureTrailingSlash = (url: string): string => {
|
||||
return url;
|
||||
@@ -436,6 +440,7 @@ export async function validateOidcCallback(
|
||||
}
|
||||
}
|
||||
|
||||
// These are the orgs that the user should be provisioned into based on the IdP mappings and the token claims
|
||||
logger.debug("User org info", { userOrgInfo });
|
||||
|
||||
let existingUserId = existingUser?.userId;
|
||||
@@ -454,15 +459,32 @@ export async function validateOidcCallback(
|
||||
);
|
||||
|
||||
if (!existingUserOrgs.length) {
|
||||
// delete all auto -provisioned user orgs
|
||||
await db
|
||||
.delete(userOrgs)
|
||||
// delete all auto-provisioned user orgs
|
||||
const autoProvisionedUserOrgs = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, existingUser.userId),
|
||||
eq(userOrgs.autoProvisioned, true)
|
||||
)
|
||||
);
|
||||
const orgIdsToRemove = autoProvisionedUserOrgs.map(
|
||||
(uo) => uo.orgId
|
||||
);
|
||||
if (orgIdsToRemove.length > 0) {
|
||||
const orgsToRemove = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(inArray(orgs.orgId, orgIdsToRemove));
|
||||
for (const org of orgsToRemove) {
|
||||
await removeUserFromOrg(
|
||||
org,
|
||||
existingUser.userId,
|
||||
db
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await calculateUserClientsForOrgs(existingUser.userId);
|
||||
|
||||
@@ -484,7 +506,7 @@ export async function validateOidcCallback(
|
||||
}
|
||||
}
|
||||
|
||||
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
||||
const orgUserCounts: { orgId: string; userCount: number }[] = [];
|
||||
|
||||
// sync the user with the orgs and roles
|
||||
await db.transaction(async (trx) => {
|
||||
@@ -538,15 +560,14 @@ export async function validateOidcCallback(
|
||||
);
|
||||
|
||||
if (orgsToDelete.length > 0) {
|
||||
await trx.delete(userOrgs).where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId!),
|
||||
inArray(
|
||||
userOrgs.orgId,
|
||||
orgsToDelete.map((org) => org.orgId)
|
||||
)
|
||||
)
|
||||
);
|
||||
const orgIdsToRemove = orgsToDelete.map((org) => org.orgId);
|
||||
const fullOrgsToRemove = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(inArray(orgs.orgId, orgIdsToRemove));
|
||||
for (const org of fullOrgsToRemove) {
|
||||
await removeUserFromOrg(org, userId!, trx);
|
||||
}
|
||||
}
|
||||
|
||||
// Update roles for existing auto-provisioned orgs where the role has changed
|
||||
@@ -587,15 +608,24 @@ export async function validateOidcCallback(
|
||||
);
|
||||
|
||||
if (orgsToAdd.length > 0) {
|
||||
await trx.insert(userOrgs).values(
|
||||
orgsToAdd.map((org) => ({
|
||||
userId: userId!,
|
||||
orgId: org.orgId,
|
||||
roleId: org.roleId,
|
||||
autoProvisioned: true,
|
||||
dateCreated: new Date().toISOString()
|
||||
}))
|
||||
);
|
||||
for (const org of orgsToAdd) {
|
||||
const [fullOrg] = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, org.orgId));
|
||||
if (fullOrg) {
|
||||
await assignUserToOrg(
|
||||
fullOrg,
|
||||
{
|
||||
orgId: org.orgId,
|
||||
userId: userId!,
|
||||
roleId: org.roleId,
|
||||
autoProvisioned: true,
|
||||
},
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop through all the orgs and get the total number of users from the userOrgs table
|
||||
|
||||
@@ -866,6 +866,13 @@ authenticated.get(
|
||||
client.listClients
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/user-devices",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listClients),
|
||||
client.listUserDevices
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/client/:clientId",
|
||||
verifyApiKeyClientAccess,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import {
|
||||
domains,
|
||||
Org,
|
||||
@@ -24,13 +24,24 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { isValidCIDR } from "@server/lib/validators";
|
||||
import { createCustomer } from "#dynamic/lib/billing";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing";
|
||||
import { build } from "@server/build";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { doCidrsOverlap } from "@server/lib/ip";
|
||||
import { generateCA } from "@server/private/lib/sshCA";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
|
||||
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
|
||||
|
||||
const createOrgSchema = z.strictObject({
|
||||
orgId: z.string(),
|
||||
orgId: z
|
||||
.string()
|
||||
.min(1, "Organization ID is required")
|
||||
.max(32, "Organization ID must be at most 32 characters")
|
||||
.refine((val) => validOrgIdRegex.test(val), {
|
||||
message:
|
||||
"Organization ID must contain only lowercase letters, numbers, underscores, and single hyphens (no leading, trailing, or consecutive hyphens)"
|
||||
}),
|
||||
name: z.string().min(1).max(255),
|
||||
subnet: z
|
||||
// .union([z.cidrv4(), z.cidrv6()])
|
||||
@@ -108,6 +119,7 @@ export async function createOrg(
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
|
||||
// make sure the orgId is unique
|
||||
const orgExists = await db
|
||||
@@ -134,8 +146,74 @@ export async function createOrg(
|
||||
);
|
||||
}
|
||||
|
||||
let isFirstOrg: boolean | null = null;
|
||||
let billingOrgIdForNewOrg: string | null = null;
|
||||
if (build === "saas" && req.user) {
|
||||
const ownedOrgs = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, req.user.userId),
|
||||
eq(userOrgs.isOwner, true)
|
||||
)
|
||||
);
|
||||
if (ownedOrgs.length === 0) {
|
||||
isFirstOrg = true;
|
||||
} else {
|
||||
isFirstOrg = false;
|
||||
const [billingOrg] = await db
|
||||
.select({ orgId: orgs.orgId })
|
||||
.from(orgs)
|
||||
.innerJoin(userOrgs, eq(orgs.orgId, userOrgs.orgId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, req.user.userId),
|
||||
eq(userOrgs.isOwner, true),
|
||||
eq(orgs.isBillingOrg, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (billingOrg) {
|
||||
billingOrgIdForNewOrg = billingOrg.orgId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (build == "saas" && billingOrgIdForNewOrg) {
|
||||
const usage = await usageService.getUsage(
|
||||
billingOrgIdForNewOrg,
|
||||
FeatureId.ORGINIZATIONS
|
||||
);
|
||||
if (!usage) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"No usage data found for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
const rejectOrgs = await usageService.checkLimitSet(
|
||||
billingOrgIdForNewOrg,
|
||||
FeatureId.ORGINIZATIONS,
|
||||
{
|
||||
...usage,
|
||||
instantaneousValue: (usage.instantaneousValue || 0) + 1
|
||||
} // We need to add one to know if we are violating the limit
|
||||
);
|
||||
if (rejectOrgs) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Organization limit exceeded. Please upgrade your plan."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let error = "";
|
||||
let org: Org | null = null;
|
||||
let numOrgs: number | null = null;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const allDomains = await trx
|
||||
@@ -143,6 +221,29 @@ export async function createOrg(
|
||||
.from(domains)
|
||||
.where(eq(domains.configManaged, true));
|
||||
|
||||
const saasBillingFields =
|
||||
build === "saas" && req.user && isFirstOrg !== null
|
||||
? isFirstOrg
|
||||
? { isBillingOrg: true as const, billingOrgId: orgId } // if this is the first org, it becomes the billing org for itself
|
||||
: {
|
||||
isBillingOrg: false as const,
|
||||
billingOrgId: billingOrgIdForNewOrg
|
||||
}
|
||||
: {};
|
||||
|
||||
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({
|
||||
@@ -150,7 +251,9 @@ export async function createOrg(
|
||||
name,
|
||||
subnet,
|
||||
utilitySubnet,
|
||||
createdAt: new Date().toISOString()
|
||||
createdAt: new Date().toISOString(),
|
||||
...sshCaFields,
|
||||
...saasBillingFields
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -169,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 });
|
||||
|
||||
@@ -252,6 +356,17 @@ export async function createOrg(
|
||||
);
|
||||
|
||||
await calculateUserClientsForOrgs(ownerUserId, trx);
|
||||
|
||||
if (billingOrgIdForNewOrg) {
|
||||
const [numOrgsResult] = await trx
|
||||
.select({ count: count() })
|
||||
.from(orgs)
|
||||
.where(eq(orgs.billingOrgId, billingOrgIdForNewOrg)); // all the billable orgs including the primary org that is the billing org itself
|
||||
|
||||
numOrgs = numOrgsResult.count;
|
||||
} else {
|
||||
numOrgs = 1; // we only have one org if there is no billing org found out
|
||||
}
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
@@ -267,8 +382,8 @@ export async function createOrg(
|
||||
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error));
|
||||
}
|
||||
|
||||
if (build == "saas") {
|
||||
// make sure we have the stripe customer
|
||||
if (build === "saas" && isFirstOrg === true) {
|
||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||
const customerId = await createCustomer(orgId, req.user?.email);
|
||||
if (customerId) {
|
||||
await usageService.updateCount(
|
||||
@@ -280,6 +395,14 @@ export async function createOrg(
|
||||
}
|
||||
}
|
||||
|
||||
if (numOrgs) {
|
||||
usageService.updateCount(
|
||||
billingOrgIdForNewOrg || orgId,
|
||||
FeatureId.ORGINIZATIONS,
|
||||
numOrgs
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: org,
|
||||
success: true,
|
||||
|
||||
@@ -7,6 +7,8 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
||||
import { db, userOrgs, orgs } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
const deleteOrgSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -41,6 +43,48 @@ export async function deleteOrg(
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const [data] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, req.user!.userId)
|
||||
)
|
||||
);
|
||||
|
||||
const org = data?.orgs;
|
||||
const userOrg = data?.userOrgs;
|
||||
|
||||
if (!org || !userOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Organization with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!userOrg.isOwner) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Only organization owners can delete the organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (org.isBillingOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Cannot delete a primary organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await deleteOrgById(orgId);
|
||||
sendTerminationMessages(result);
|
||||
return response(res, {
|
||||
|
||||
@@ -40,7 +40,11 @@ const listOrgsSchema = z.object({
|
||||
// responses: {}
|
||||
// });
|
||||
|
||||
type ResponseOrg = Org & { isOwner?: boolean; isAdmin?: boolean };
|
||||
type ResponseOrg = Org & {
|
||||
isOwner?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isPrimaryOrg?: boolean;
|
||||
};
|
||||
|
||||
export type ListUserOrgsResponse = {
|
||||
orgs: ResponseOrg[];
|
||||
@@ -132,6 +136,9 @@ export async function listUserOrgs(
|
||||
if (val.roles && val.roles.isAdmin) {
|
||||
res.isAdmin = val.roles.isAdmin;
|
||||
}
|
||||
if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) {
|
||||
res.isPrimaryOrg = val.orgs.isBillingOrg;
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
userOrgs,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourceWhitelist
|
||||
resourceWhitelist,
|
||||
siteResources,
|
||||
userSiteResources,
|
||||
roleSiteResources
|
||||
} from "@server/db";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -57,9 +60,21 @@ export async function getUserResources(
|
||||
.from(roleResources)
|
||||
.where(eq(roleResources.roleId, userRoleId));
|
||||
|
||||
const [directResources, roleResourceResults] = await Promise.all([
|
||||
const directSiteResourcesQuery = db
|
||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||
.from(userSiteResources)
|
||||
.where(eq(userSiteResources.userId, userId));
|
||||
|
||||
const roleSiteResourcesQuery = db
|
||||
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
||||
.from(roleSiteResources)
|
||||
.where(eq(roleSiteResources.roleId, userRoleId));
|
||||
|
||||
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
|
||||
directResourcesQuery,
|
||||
roleResourcesQuery
|
||||
roleResourcesQuery,
|
||||
directSiteResourcesQuery,
|
||||
roleSiteResourcesQuery
|
||||
]);
|
||||
|
||||
// Combine all accessible resource IDs
|
||||
@@ -68,18 +83,25 @@ export async function getUserResources(
|
||||
...roleResourceResults.map((r) => r.resourceId)
|
||||
];
|
||||
|
||||
if (accessibleResourceIds.length === 0) {
|
||||
return response(res, {
|
||||
data: { resources: [] },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "No resources found",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
// Combine all accessible site resource IDs
|
||||
const accessibleSiteResourceIds = [
|
||||
...directSiteResourceResults.map((r) => r.siteResourceId),
|
||||
...roleSiteResourceResults.map((r) => r.siteResourceId)
|
||||
];
|
||||
|
||||
// Get resource details for accessible resources
|
||||
const resourcesData = await db
|
||||
let resourcesData: Array<{
|
||||
resourceId: number;
|
||||
name: string;
|
||||
fullDomain: string | null;
|
||||
ssl: boolean;
|
||||
enabled: boolean;
|
||||
sso: boolean;
|
||||
protocol: string;
|
||||
emailWhitelistEnabled: boolean;
|
||||
}> = [];
|
||||
if (accessibleResourceIds.length > 0) {
|
||||
resourcesData = await db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
@@ -98,6 +120,40 @@ export async function getUserResources(
|
||||
eq(resources.enabled, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get site resource details for accessible site resources
|
||||
let siteResourcesData: Array<{
|
||||
siteResourceId: number;
|
||||
name: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
protocol: string | null;
|
||||
enabled: boolean;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
}> = [];
|
||||
if (accessibleSiteResourceIds.length > 0) {
|
||||
siteResourcesData = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name,
|
||||
destination: siteResources.destination,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
enabled: siteResources.enabled,
|
||||
alias: siteResources.alias,
|
||||
aliasAddress: siteResources.aliasAddress
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check for password, pincode, and whitelist protection for each resource
|
||||
const resourcesWithAuth = await Promise.all(
|
||||
@@ -161,8 +217,26 @@ export async function getUserResources(
|
||||
})
|
||||
);
|
||||
|
||||
// Format site resources
|
||||
const siteResourcesFormatted = siteResourcesData.map((siteResource) => {
|
||||
return {
|
||||
siteResourceId: siteResource.siteResourceId,
|
||||
name: siteResource.name,
|
||||
destination: siteResource.destination,
|
||||
mode: siteResource.mode,
|
||||
protocol: siteResource.protocol,
|
||||
enabled: siteResource.enabled,
|
||||
alias: siteResource.alias,
|
||||
aliasAddress: siteResource.aliasAddress,
|
||||
type: 'site' as const
|
||||
};
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: { resources: resourcesWithAuth },
|
||||
data: {
|
||||
resources: resourcesWithAuth,
|
||||
siteResources: siteResourcesFormatted
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resources retrieved successfully",
|
||||
@@ -190,5 +264,16 @@ export type GetUserResourcesResponse = {
|
||||
protected: boolean;
|
||||
protocol: string;
|
||||
}>;
|
||||
siteResources: Array<{
|
||||
siteResourceId: number;
|
||||
name: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
protocol: string | null;
|
||||
enabled: boolean;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
type: 'site';
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,74 +1,99 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility
|
||||
} from "@server/db";
|
||||
import {
|
||||
resources,
|
||||
userResources,
|
||||
roleResources,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resources,
|
||||
roleResources,
|
||||
targetHealthCheck,
|
||||
targets,
|
||||
targetHealthCheck
|
||||
userResources
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
like,
|
||||
not,
|
||||
or,
|
||||
sql,
|
||||
type SQL
|
||||
} from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const listResourcesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listResourcesSchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
enabled: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter resources based on enabled status"
|
||||
}),
|
||||
authState: z
|
||||
.enum(["protected", "not_protected", "none"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["protected", "not_protected", "none"],
|
||||
description:
|
||||
"Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)."
|
||||
}),
|
||||
healthStatus: z
|
||||
.enum(["no_targets", "healthy", "degraded", "offline", "unknown"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
|
||||
description:
|
||||
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
|
||||
})
|
||||
});
|
||||
|
||||
// (resource fields + a single joined target)
|
||||
type JoinedRow = {
|
||||
resourceId: number;
|
||||
niceId: string;
|
||||
name: string;
|
||||
ssl: boolean;
|
||||
fullDomain: string | null;
|
||||
passwordId: number | null;
|
||||
sso: boolean;
|
||||
pincodeId: number | null;
|
||||
whitelist: boolean;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
enabled: boolean;
|
||||
domainId: string | null;
|
||||
headerAuthId: number | null;
|
||||
|
||||
targetId: number | null;
|
||||
targetIp: string | null;
|
||||
targetPort: number | null;
|
||||
targetEnabled: boolean | null;
|
||||
|
||||
hcHealth: string | null;
|
||||
hcEnabled: boolean | null;
|
||||
};
|
||||
|
||||
// grouped by resource with targets[])
|
||||
export type ResourceWithTargets = {
|
||||
resourceId: number;
|
||||
@@ -91,11 +116,32 @@ export type ResourceWithTargets = {
|
||||
ip: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
||||
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function queryResources(accessibleResourceIds: number[], orgId: string) {
|
||||
// Aggregate filters
|
||||
const total_targets = count(targets.targetId);
|
||||
const healthy_targets = sql<number>`SUM(
|
||||
CASE
|
||||
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
) `;
|
||||
const unknown_targets = sql<number>`SUM(
|
||||
CASE
|
||||
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
) `;
|
||||
const unhealthy_targets = sql<number>`SUM(
|
||||
CASE
|
||||
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
) `;
|
||||
|
||||
function queryResourcesBase() {
|
||||
return db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
@@ -114,14 +160,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
||||
niceId: resources.niceId,
|
||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||
headerAuthExtendedCompatibilityId:
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
||||
targetId: targets.targetId,
|
||||
targetIp: targets.ip,
|
||||
targetPort: targets.port,
|
||||
targetEnabled: targets.enabled,
|
||||
|
||||
hcHealth: targetHealthCheck.hcHealth,
|
||||
hcEnabled: targetHealthCheck.hcEnabled
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
@@ -148,18 +187,18 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
.groupBy(
|
||||
resources.resourceId,
|
||||
resourcePassword.passwordId,
|
||||
resourcePincode.pincodeId,
|
||||
resourceHeaderAuth.headerAuthId,
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||
);
|
||||
}
|
||||
|
||||
export type ListResourcesResponse = {
|
||||
export type ListResourcesResponse = PaginatedResponse<{
|
||||
resources: ResourceWithTargets[];
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
}>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
@@ -190,7 +229,8 @@ export async function listResources(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
const { page, pageSize, authState, enabled, query, healthStatus } =
|
||||
parsedQuery.data;
|
||||
|
||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -252,14 +292,133 @@ export async function listResources(
|
||||
(resource) => resource.resourceId
|
||||
);
|
||||
|
||||
const countQuery: any = db
|
||||
.select({ count: count() })
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, accessibleResourceIds));
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
];
|
||||
|
||||
const baseQuery = queryResources(accessibleResourceIds, orgId);
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${resources.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resources.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resources.fullDomain})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (typeof enabled !== "undefined") {
|
||||
conditions.push(eq(resources.enabled, enabled));
|
||||
}
|
||||
|
||||
const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset);
|
||||
if (typeof authState !== "undefined") {
|
||||
switch (authState) {
|
||||
case "none":
|
||||
conditions.push(eq(resources.http, false));
|
||||
break;
|
||||
case "protected":
|
||||
conditions.push(
|
||||
or(
|
||||
eq(resources.sso, true),
|
||||
eq(resources.emailWhitelistEnabled, true),
|
||||
not(isNull(resourceHeaderAuth.headerAuthId)),
|
||||
not(isNull(resourcePincode.pincodeId)),
|
||||
not(isNull(resourcePassword.passwordId))
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "not_protected":
|
||||
conditions.push(
|
||||
not(eq(resources.sso, true)),
|
||||
not(eq(resources.emailWhitelistEnabled, true)),
|
||||
isNull(resourceHeaderAuth.headerAuthId),
|
||||
isNull(resourcePincode.pincodeId),
|
||||
isNull(resourcePassword.passwordId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let aggregateFilters: SQL<any> | undefined = sql`1 = 1`;
|
||||
|
||||
if (typeof healthStatus !== "undefined") {
|
||||
switch (healthStatus) {
|
||||
case "healthy":
|
||||
aggregateFilters = and(
|
||||
sql`${total_targets} > 0`,
|
||||
sql`${healthy_targets} = ${total_targets}`
|
||||
);
|
||||
break;
|
||||
case "degraded":
|
||||
aggregateFilters = and(
|
||||
sql`${total_targets} > 0`,
|
||||
sql`${unhealthy_targets} > 0`
|
||||
);
|
||||
break;
|
||||
case "no_targets":
|
||||
aggregateFilters = sql`${total_targets} = 0`;
|
||||
break;
|
||||
case "offline":
|
||||
aggregateFilters = and(
|
||||
sql`${total_targets} > 0`,
|
||||
sql`${healthy_targets} = 0`,
|
||||
sql`${unhealthy_targets} = ${total_targets}`
|
||||
);
|
||||
break;
|
||||
case "unknown":
|
||||
aggregateFilters = and(
|
||||
sql`${total_targets} > 0`,
|
||||
sql`${unknown_targets} = ${total_targets}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const baseQuery = queryResourcesBase()
|
||||
.where(and(...conditions))
|
||||
.having(aggregateFilters);
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(baseQuery.as("filtered_resources"));
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(resources.resourceId)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const resourceIdList = rows.map((row) => row.resourceId);
|
||||
const allResourceTargets =
|
||||
resourceIdList.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
targetId: targets.targetId,
|
||||
resourceId: targets.resourceId,
|
||||
ip: targets.ip,
|
||||
port: targets.port,
|
||||
enabled: targets.enabled,
|
||||
healthStatus: targetHealthCheck.hcHealth,
|
||||
hcEnabled: targetHealthCheck.hcEnabled
|
||||
})
|
||||
.from(targets)
|
||||
.where(inArray(targets.resourceId, resourceIdList))
|
||||
.leftJoin(
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
);
|
||||
|
||||
// avoids TS issues with reduce/never[]
|
||||
const map = new Map<number, ResourceWithTargets>();
|
||||
@@ -288,44 +447,20 @@ export async function listResources(
|
||||
map.set(row.resourceId, entry);
|
||||
}
|
||||
|
||||
if (
|
||||
row.targetId != null &&
|
||||
row.targetIp &&
|
||||
row.targetPort != null &&
|
||||
row.targetEnabled != null
|
||||
) {
|
||||
let healthStatus: "healthy" | "unhealthy" | "unknown" =
|
||||
"unknown";
|
||||
|
||||
if (row.hcEnabled && row.hcHealth) {
|
||||
healthStatus = row.hcHealth as
|
||||
| "healthy"
|
||||
| "unhealthy"
|
||||
| "unknown";
|
||||
}
|
||||
|
||||
entry.targets.push({
|
||||
targetId: row.targetId,
|
||||
ip: row.targetIp,
|
||||
port: row.targetPort,
|
||||
enabled: row.targetEnabled,
|
||||
healthStatus: healthStatus
|
||||
});
|
||||
}
|
||||
entry.targets = allResourceTargets.filter(
|
||||
(t) => t.resourceId === entry.resourceId
|
||||
);
|
||||
}
|
||||
|
||||
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
||||
|
||||
const totalCountResult = await countQuery;
|
||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||
|
||||
return response<ListResourcesResponse>(res, {
|
||||
data: {
|
||||
resources: resourcesList,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
offset
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -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).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;
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = { ...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",
|
||||
|
||||
@@ -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, count } from "drizzle-orm";
|
||||
import { getUniqueSiteName } from "../../db/names";
|
||||
import { addPeer } from "../gerbil/peers";
|
||||
import { fromError } from "zod-validation-error";
|
||||
@@ -288,7 +288,6 @@ export async function createSite(
|
||||
const niceId = await getUniqueSiteName(orgId);
|
||||
|
||||
let newSite: Site | undefined;
|
||||
let numSites: Site[] | undefined;
|
||||
await db.transaction(async (trx) => {
|
||||
if (type == "newt") {
|
||||
[newSite] = await trx
|
||||
@@ -443,20 +442,9 @@ export async function createSite(
|
||||
});
|
||||
}
|
||||
|
||||
numSites = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.orgId, orgId));
|
||||
await usageService.add(orgId, FeatureId.SITES, 1, trx);
|
||||
});
|
||||
|
||||
if (numSites) {
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.SITES,
|
||||
numSites.length
|
||||
);
|
||||
}
|
||||
|
||||
if (!newSite) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
@@ -64,7 +64,6 @@ export async function deleteSite(
|
||||
}
|
||||
|
||||
let deletedNewtId: string | null = null;
|
||||
let numSites: Site[] | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (site.type == "wireguard") {
|
||||
@@ -103,19 +102,9 @@ export async function deleteSite(
|
||||
|
||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||
|
||||
numSites = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.orgId, site.orgId));
|
||||
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
||||
});
|
||||
|
||||
if (numSites) {
|
||||
await usageService.updateCount(
|
||||
site.orgId,
|
||||
FeatureId.SITES,
|
||||
numSites.length
|
||||
);
|
||||
}
|
||||
// Send termination message outside of transaction to prevent blocking
|
||||
if (deletedNewtId) {
|
||||
const payload = {
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { db, exitNodes, newts } from "@server/db";
|
||||
import { orgs, roleSites, sites, userSites } from "@server/db";
|
||||
import { remoteExitNodes } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
db,
|
||||
exitNodes,
|
||||
newts,
|
||||
orgs,
|
||||
remoteExitNodes,
|
||||
roleSites,
|
||||
sites,
|
||||
userSites
|
||||
} from "@server/db";
|
||||
import cache from "@server/lib/cache";
|
||||
import response from "@server/lib/response";
|
||||
import { and, count, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import semver from "semver";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import semver from "semver";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
async function getLatestNewtVersion(): Promise<string | null> {
|
||||
try {
|
||||
@@ -74,21 +82,63 @@ const listSitesParamsSchema = z.strictObject({
|
||||
});
|
||||
|
||||
const listSitesSchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["megabytesIn", "megabytesOut"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["megabytesIn", "megabytesOut"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
.enum(["asc", "desc"])
|
||||
.optional()
|
||||
.default("asc")
|
||||
.catch("asc")
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
online: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter by online status"
|
||||
})
|
||||
});
|
||||
|
||||
function querySites(orgId: string, accessibleSiteIds: number[]) {
|
||||
function querySitesBase() {
|
||||
return db
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
@@ -115,23 +165,16 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
|
||||
.leftJoin(
|
||||
remoteExitNodes,
|
||||
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(sites.siteId, accessibleSiteIds),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySites>>[0] & {
|
||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
|
||||
newtUpdateAvailable?: boolean;
|
||||
};
|
||||
|
||||
export type ListSitesResponse = {
|
||||
export type ListSitesResponse = PaginatedResponse<{
|
||||
sites: SiteWithUpdateAvailable[];
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
}>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
@@ -160,7 +203,6 @@ export async function listSites(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -203,34 +245,67 @@ export async function listSites(
|
||||
.where(eq(sites.orgId, orgId));
|
||||
}
|
||||
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
const baseQuery = querySites(orgId, accessibleSiteIds);
|
||||
const { pageSize, page, query, sort_by, order, online } =
|
||||
parsedQuery.data;
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: count() })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
inArray(sites.siteId, accessibleSiteIds),
|
||||
eq(sites.orgId, orgId)
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(sites.siteId, accessibleSiteIds),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
];
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${sites.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${sites.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(sites.online, online));
|
||||
}
|
||||
|
||||
const sitesList = await baseQuery.limit(limit).offset(offset);
|
||||
const totalCountResult = await countQuery;
|
||||
const totalCount = totalCountResult[0].count;
|
||||
const baseQuery = querySitesBase().where(and(...conditions));
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(
|
||||
querySitesBase().where(and(...conditions))
|
||||
);
|
||||
|
||||
const siteListQuery = baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(
|
||||
sort_by
|
||||
? order === "asc"
|
||||
? asc(sites[sort_by])
|
||||
: desc(sites[sort_by])
|
||||
: asc(sites.siteId)
|
||||
);
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
siteListQuery
|
||||
]);
|
||||
|
||||
// Get latest version asynchronously without blocking the response
|
||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||
|
||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map(
|
||||
(site) => {
|
||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||
// Initially set to false, will be updated if version check succeeds
|
||||
siteWithUpdate.newtUpdateAvailable = false;
|
||||
return siteWithUpdate;
|
||||
}
|
||||
);
|
||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||
// Initially set to false, will be updated if version check succeeds
|
||||
siteWithUpdate.newtUpdateAvailable = false;
|
||||
return siteWithUpdate;
|
||||
});
|
||||
|
||||
// Try to get the latest version, but don't block if it fails
|
||||
try {
|
||||
@@ -267,8 +342,8 @@ export async function listSites(
|
||||
sites: sitesWithUpdates,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
offset
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
// 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;
|
||||
|
||||
@@ -1,41 +1,92 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { siteResources, sites, SiteResource } from "@server/db";
|
||||
import { db, SiteResource, siteResources, sites } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import { and, asc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
mode: z
|
||||
.enum(["host", "cidr"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["host", "cidr"],
|
||||
description: "Filter site resources by mode"
|
||||
})
|
||||
});
|
||||
|
||||
export type ListAllSiteResourcesByOrgResponse = {
|
||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
siteResources: (SiteResource & {
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
siteAddress: string | null;
|
||||
})[];
|
||||
};
|
||||
}>;
|
||||
|
||||
function querySiteResourcesBase() {
|
||||
return db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
siteId: siteResources.siteId,
|
||||
orgId: siteResources.orgId,
|
||||
niceId: siteResources.niceId,
|
||||
name: siteResources.name,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
proxyPort: siteResources.proxyPort,
|
||||
destinationPort: siteResources.destinationPort,
|
||||
destination: siteResources.destination,
|
||||
enabled: siteResources.enabled,
|
||||
alias: siteResources.alias,
|
||||
aliasAddress: siteResources.aliasAddress,
|
||||
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||
udpPortRangeString: siteResources.udpPortRangeString,
|
||||
disableIcmp: siteResources.disableIcmp,
|
||||
authDaemonMode: siteResources.authDaemonMode,
|
||||
authDaemonPort: siteResources.authDaemonPort,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteAddress: sites.address
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
@@ -80,39 +131,67 @@ export async function listAllSiteResourcesByOrg(
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
const { page, pageSize, query, mode } = parsedQuery.data;
|
||||
|
||||
// Get all site resources for the org with site names
|
||||
const siteResourcesList = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
siteId: siteResources.siteId,
|
||||
orgId: siteResources.orgId,
|
||||
niceId: siteResources.niceId,
|
||||
name: siteResources.name,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
proxyPort: siteResources.proxyPort,
|
||||
destinationPort: siteResources.destinationPort,
|
||||
destination: siteResources.destination,
|
||||
enabled: siteResources.enabled,
|
||||
alias: siteResources.alias,
|
||||
aliasAddress: siteResources.aliasAddress,
|
||||
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||
udpPortRangeString: siteResources.udpPortRangeString,
|
||||
disableIcmp: siteResources.disableIcmp,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteAddress: sites.address
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId))
|
||||
.where(eq(siteResources.orgId, orgId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${siteResources.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.destination})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.alias})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.aliasAddress})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${sites.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: { siteResources: siteResourcesList },
|
||||
if (mode) {
|
||||
conditions.push(eq(siteResources.mode, mode));
|
||||
}
|
||||
|
||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
querySiteResourcesBase().where(and(...conditions))
|
||||
);
|
||||
|
||||
const [siteResourcesList, totalCount] = await Promise.all([
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(siteResources.siteResourceId)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||
data: {
|
||||
siteResources: siteResourcesList,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site resources retrieved successfully",
|
||||
|
||||
@@ -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())
|
||||
@@ -41,6 +43,7 @@ const updateSiteResourceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
siteId: z.int(),
|
||||
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||
mode: z.enum(["host", "cidr"]).optional(),
|
||||
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
||||
@@ -60,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(
|
||||
@@ -171,7 +176,9 @@ export async function updateSiteResource(
|
||||
clientIds,
|
||||
tcpPortRangeString,
|
||||
udpPortRangeString,
|
||||
disableIcmp
|
||||
disableIcmp,
|
||||
authDaemonPort,
|
||||
authDaemonMode
|
||||
} = parsedBody.data;
|
||||
|
||||
const [site] = await db
|
||||
@@ -197,6 +204,11 @@ export async function updateSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
const isLicensedSshPam = await isLicensedOrSubscribed(
|
||||
existingSiteResource.orgId,
|
||||
tierMatrix.sshPam
|
||||
);
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
@@ -307,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({
|
||||
@@ -318,7 +342,8 @@ export async function updateSiteResource(
|
||||
alias: alias && alias.trim() ? alias : null,
|
||||
tcpPortRangeString: tcpPortRangeString,
|
||||
udpPortRangeString: udpPortRangeString,
|
||||
disableIcmp: disableIcmp
|
||||
disableIcmp: disableIcmp,
|
||||
...sshPamSet
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
@@ -396,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({
|
||||
@@ -407,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))
|
||||
|
||||
@@ -105,7 +105,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
await db
|
||||
.update(targetHealthCheck)
|
||||
.set({
|
||||
hcHealth: healthStatus.status
|
||||
hcHealth: healthStatus.status as
|
||||
| "unknown"
|
||||
| "healthy"
|
||||
| "unhealthy"
|
||||
})
|
||||
.where(eq(targetHealthCheck.targetId, targetIdNum))
|
||||
.execute();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, UserOrg } from "@server/db";
|
||||
import { db, orgs, UserOrg } from "@server/db";
|
||||
import { roles, userInvites, userOrgs, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, inArray, ne } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -14,6 +14,7 @@ import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { build } from "@server/build";
|
||||
import { assignUserToOrg } from "@server/lib/userOrg";
|
||||
|
||||
const acceptInviteBodySchema = z.strictObject({
|
||||
token: z.string(),
|
||||
@@ -125,8 +126,22 @@ export async function acceptInvite(
|
||||
}
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, existingInvite.orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization does not exist. Please contact an admin."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let roleId: number;
|
||||
let totalUsers: UserOrg[] | undefined;
|
||||
// get the role to make sure it exists
|
||||
const existingRole = await db
|
||||
.select()
|
||||
@@ -146,12 +161,15 @@ export async function acceptInvite(
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// add the user to the org
|
||||
await trx.insert(userOrgs).values({
|
||||
userId: existingUser[0].userId,
|
||||
orgId: existingInvite.orgId,
|
||||
roleId: existingInvite.roleId
|
||||
});
|
||||
await assignUserToOrg(
|
||||
org,
|
||||
{
|
||||
userId: existingUser[0].userId,
|
||||
orgId: existingInvite.orgId,
|
||||
roleId: existingInvite.roleId
|
||||
},
|
||||
trx
|
||||
);
|
||||
|
||||
// delete the invite
|
||||
await trx
|
||||
@@ -160,25 +178,11 @@ export async function acceptInvite(
|
||||
|
||||
await calculateUserClientsForOrgs(existingUser[0].userId, trx);
|
||||
|
||||
// Get the total number of users in the org now
|
||||
totalUsers = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, existingInvite.orgId));
|
||||
|
||||
logger.debug(
|
||||
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}`
|
||||
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}`
|
||||
);
|
||||
});
|
||||
|
||||
if (totalUsers) {
|
||||
await usageService.updateCount(
|
||||
existingInvite.orgId,
|
||||
FeatureId.USERS,
|
||||
totalUsers.length
|
||||
);
|
||||
}
|
||||
|
||||
return response<AcceptInviteResponse>(res, {
|
||||
data: { accepted: true, orgId: existingInvite.orgId },
|
||||
success: true,
|
||||
|
||||
@@ -6,8 +6,8 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { db, UserOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, orgs, UserOrg } from "@server/db";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
@@ -16,6 +16,7 @@ import { build } from "@server/build";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { assignUserToOrg } from "@server/lib/userOrg";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -151,6 +152,21 @@ export async function createOrgUser(
|
||||
);
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Organization not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [idpRes] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
@@ -172,8 +188,6 @@ export async function createOrgUser(
|
||||
);
|
||||
}
|
||||
|
||||
let orgUsers: UserOrg[] | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const [existingUser] = await trx
|
||||
.select()
|
||||
@@ -207,15 +221,12 @@ export async function createOrgUser(
|
||||
);
|
||||
}
|
||||
|
||||
await trx
|
||||
.insert(userOrgs)
|
||||
.values({
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
})
|
||||
.returning();
|
||||
await assignUserToOrg(org, {
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
}, trx);
|
||||
} else {
|
||||
userId = generateId(15);
|
||||
|
||||
@@ -233,33 +244,16 @@ export async function createOrgUser(
|
||||
})
|
||||
.returning();
|
||||
|
||||
await trx
|
||||
.insert(userOrgs)
|
||||
.values({
|
||||
await assignUserToOrg(org, {
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
})
|
||||
.returning();
|
||||
}, trx);
|
||||
}
|
||||
|
||||
// List all of the users in the org
|
||||
orgUsers = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, orgId));
|
||||
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
});
|
||||
|
||||
if (orgUsers) {
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.USERS,
|
||||
orgUsers.length
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resources, sites, UserOrg } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
orgs,
|
||||
resources,
|
||||
siteResources,
|
||||
sites,
|
||||
UserOrg,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { userOrgs, userResources, users, userSites } from "@server/db";
|
||||
import { and, count, eq, exists } from "drizzle-orm";
|
||||
import { and, count, eq, exists, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -14,6 +22,7 @@ import { FeatureId } from "@server/lib/billing";
|
||||
import { build } from "@server/build";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { removeUserFromOrg } from "@server/lib/userOrg";
|
||||
|
||||
const removeUserSchema = z.strictObject({
|
||||
userId: z.string(),
|
||||
@@ -50,16 +59,16 @@ export async function removeUserOrg(
|
||||
const { userId, orgId } = parsedParams.data;
|
||||
|
||||
// get the user first
|
||||
const user = await db
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)));
|
||||
|
||||
if (!user || user.length === 0) {
|
||||
if (!user) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
if (user[0].isOwner) {
|
||||
if (user.isOwner) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -68,56 +77,20 @@ export async function removeUserOrg(
|
||||
);
|
||||
}
|
||||
|
||||
let userCount: UserOrg[] | undefined;
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(userOrgs)
|
||||
.where(
|
||||
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||
);
|
||||
|
||||
await db.delete(userResources).where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
exists(
|
||||
db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
resources.resourceId,
|
||||
userResources.resourceId
|
||||
),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await db.delete(userSites).where(
|
||||
and(
|
||||
eq(userSites.userId, userId),
|
||||
exists(
|
||||
db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, userSites.siteId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
userCount = await trx
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, orgId));
|
||||
await removeUserFromOrg(org, userId, trx);
|
||||
|
||||
// if (build === "saas") {
|
||||
// const [rootUser] = await trx
|
||||
@@ -139,14 +112,6 @@ export async function removeUserOrg(
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
});
|
||||
|
||||
if (userCount) {
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.USERS,
|
||||
userCount.length
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
||||
85
server/routers/ws/checkRoundTripMessage.ts
Normal file
85
server/routers/ws/checkRoundTripMessage.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, roundTripMessageTracker } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const checkRoundTripMessageParamsSchema = z
|
||||
.object({
|
||||
messageId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
})
|
||||
.strict();
|
||||
|
||||
// registry.registerPath({
|
||||
// method: "get",
|
||||
// path: "/ws/round-trip-message/{messageId}",
|
||||
// description:
|
||||
// "Check if a round trip message has been completed by checking the roundTripMessageTracker table",
|
||||
// tags: [OpenAPITags.WebSocket],
|
||||
// request: {
|
||||
// params: checkRoundTripMessageParamsSchema
|
||||
// },
|
||||
// responses: {}
|
||||
// });
|
||||
|
||||
export async function checkRoundTripMessage(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = checkRoundTripMessageParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { messageId } = parsedParams.data;
|
||||
|
||||
// Get the round trip message from the tracker
|
||||
const [message] = await db
|
||||
.select()
|
||||
.from(roundTripMessageTracker)
|
||||
.where(eq(roundTripMessageTracker.messageId, messageId))
|
||||
.limit(1);
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Message not found")
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
complete: message.complete,
|
||||
sentAt: message.sentAt,
|
||||
receivedAt: message.receivedAt,
|
||||
error: message.error,
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Round trip message status retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
49
server/routers/ws/handleRoundTripMessage.ts
Normal file
49
server/routers/ws/handleRoundTripMessage.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { db, roundTripMessageTracker } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
interface RoundTripCompleteMessage {
|
||||
messageId: number;
|
||||
complete: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const handleRoundTripMessage: MessageHandler = async (
|
||||
context
|
||||
) => {
|
||||
const { message, client: c } = context;
|
||||
|
||||
logger.info("Handling round trip message");
|
||||
|
||||
const data = message.data as RoundTripCompleteMessage;
|
||||
|
||||
try {
|
||||
const { messageId, complete, error } = data;
|
||||
|
||||
if (!messageId) {
|
||||
logger.error("Round trip message missing messageId");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the roundTripMessageTracker with completion status
|
||||
await db
|
||||
.update(roundTripMessageTracker)
|
||||
.set({
|
||||
complete: complete,
|
||||
receivedAt: Math.floor(Date.now() / 1000),
|
||||
error: error || null
|
||||
})
|
||||
.where(eq(roundTripMessageTracker.messageId, messageId));
|
||||
|
||||
logger.info(`Round trip message ${messageId} marked as complete: ${complete}`);
|
||||
|
||||
if (error) {
|
||||
logger.warn(`Round trip message ${messageId} completed with error: ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error processing round trip message:", error);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./ws";
|
||||
export * from "./types";
|
||||
export * from "./checkRoundTripMessage";
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
handleOlmDisconnecingMessage
|
||||
} from "../olm";
|
||||
import { handleHealthcheckStatusMessage } from "../target";
|
||||
import { handleRoundTripMessage } from "./handleRoundTripMessage";
|
||||
import { MessageHandler } from "./types";
|
||||
|
||||
export const messageHandlers: Record<string, MessageHandler> = {
|
||||
@@ -35,7 +36,8 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"newt/socket/containers": handleDockerContainersMessage,
|
||||
"newt/ping/request": handleNewtPingRequestMessage,
|
||||
"newt/blueprint/apply": handleApplyBlueprintMessage,
|
||||
"newt/healthcheck/status": handleHealthcheckStatusMessage
|
||||
"newt/healthcheck/status": handleHealthcheckStatusMessage,
|
||||
"ws/round-trip/complete": handleRoundTripMessage
|
||||
};
|
||||
|
||||
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
||||
|
||||
29
server/setup/scriptsSqlite/1.16.0.ts
Normal file
29
server/setup/scriptsSqlite/1.16.0.ts
Normal file
@@ -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 orgs
|
||||
|
||||
try {
|
||||
db.transaction(() => {})();
|
||||
|
||||
console.log(`Migrated database`);
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
5
server/types/Pagination.ts
Normal file
5
server/types/Pagination.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Pagination = { total: number; pageSize: number; page: number };
|
||||
|
||||
export type PaginatedResponse<T> = T & {
|
||||
pagination: Pagination;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type BillingSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -17,6 +18,9 @@ export default async function BillingSettingsPage({
|
||||
params
|
||||
}: BillingSettingsProps) {
|
||||
const { orgId } = await params;
|
||||
if (build !== "saas") {
|
||||
redirect(`/${orgId}/settings`);
|
||||
}
|
||||
|
||||
const user = await verifySession();
|
||||
|
||||
@@ -40,6 +44,10 @@ export default async function BillingSettingsPage({
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
if (!(org?.org?.isBillingOrg && orgUser?.isOwner)) {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
|
||||
@@ -110,37 +110,42 @@ const planOptions: PlanOption[] = [
|
||||
// Tier limits mapping derived from limit sets
|
||||
const tierLimits: Record<
|
||||
Tier | "basic",
|
||||
{ users: number; sites: number; domains: number; remoteNodes: number }
|
||||
{ users: number; sites: number; domains: number; remoteNodes: number; organizations: number }
|
||||
> = {
|
||||
basic: {
|
||||
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: freeLimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier1: {
|
||||
users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier1LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier2: {
|
||||
users: tier2LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier2LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier3: {
|
||||
users: tier3LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier3LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
enterprise: {
|
||||
users: 0, // Custom for enterprise
|
||||
sites: 0, // Custom for enterprise
|
||||
domains: 0, // Custom for enterprise
|
||||
remoteNodes: 0 // Custom for enterprise
|
||||
remoteNodes: 0, // Custom for enterprise
|
||||
organizations: 0 // Custom for enterprise
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,6 +184,7 @@ export default function BillingPage() {
|
||||
const SITES = "sites";
|
||||
const DOMAINS = "domains";
|
||||
const REMOTE_EXIT_NODES = "remoteExitNodes";
|
||||
const ORGINIZATIONS = "organizations";
|
||||
|
||||
// Confirmation dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
@@ -619,6 +625,16 @@ export default function BillingPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// Check organizations
|
||||
const organizationsUsage = getUsageValue(ORGINIZATIONS);
|
||||
if (limits.organizations > 0 && organizationsUsage > limits.organizations) {
|
||||
violations.push({
|
||||
feature: "Organizations",
|
||||
currentUsage: organizationsUsage,
|
||||
newLimit: limits.organizations
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
};
|
||||
|
||||
@@ -752,7 +768,7 @@ export default function BillingPage() {
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
{t("billingMaximumLimits") || "Maximum Limits"}
|
||||
</div>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSections cols={5}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingUsers") || "Users"}
|
||||
@@ -855,6 +871,41 @@ export default function BillingPage() {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingOrganizations") ||
|
||||
"Organizations"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{isOverLimit(ORGINIZATIONS) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(ORGINIZATIONS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(ORGINIZATIONS) !==
|
||||
null && "orgs"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(ORGINIZATIONS), limit: getLimitValue(ORGINIZATIONS) ?? 0 }) || `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(ORGINIZATIONS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(ORGINIZATIONS) !==
|
||||
null && "orgs"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingRemoteNodes") ||
|
||||
@@ -872,7 +923,7 @@ export default function BillingPage() {
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "remote nodes"}
|
||||
null && "nodes"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -885,7 +936,7 @@ export default function BillingPage() {
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "remote nodes"}
|
||||
null && "nodes"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -1016,6 +1067,17 @@ export default function BillingPage() {
|
||||
"Domains"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.organizations
|
||||
}{" "}
|
||||
{t("billingOrganizations") ||
|
||||
"Organizations"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user